From cc8a84dc7fa486f4c4620c0d52ce3f4dc114ff1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 20:27:44 +0000 Subject: [PATCH 1/5] Add standalone benchmarker Flutter app Adds benchmarker/, a cross-platform (Android, iOS, Linux, macOS, Windows) Flutter app that benchmarks HTTP responses from the http_cache_stream cache server. It imports the package by path so it always measures the working copy. Workers run on a pool of long-lived isolates. Each isolate builds one http.Client from an HttpClientBuilder sent at spawn time and reuses it for every request, so other client implementations can be benchmarked by adding an entry to kHttpClientOptions. The pool is respawned only when the concurrency or client implementation changes. Inputs are the source URL, the concurrency (isolates) and the total request count, divided evenly between workers. Three run types are supported: pre-cached (fully download first), non-cached (wipe the cache first) and direct (bypass http_cache_stream and hit the source URL). Workers stream per-request measurements back to the main isolate, which aggregates them and refreshes the UI four times a second: avg/p50/p90/p99/min/ max for time to headers, time to first byte and completion time, plus requests per second, bytes per second, byte totals and an outcome breakdown. Every response's byte count is verified against its Content-Length. Cache-server runs also show live download progress and errors from cacheStateStream, alongside a read-only log field for status and errors. Includes unit tests for request distribution and statistics, an isolate-pool test against a local origin server, and end-to-end tests of all three run types through a real HttpCacheManager. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JvD7reUpdgiJeHaJz2XSE --- benchmarker/.gitignore | 45 ++ benchmarker/.metadata | 42 + benchmarker/README.md | 98 +++ benchmarker/analysis_options.yaml | 28 + benchmarker/android/.gitignore | 14 + benchmarker/android/app/build.gradle.kts | 45 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 47 ++ .../benchmarker/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../main/res/xml/network_security_config.xml | 7 + .../app/src/profile/AndroidManifest.xml | 7 + benchmarker/android/build.gradle.kts | 24 + benchmarker/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + benchmarker/android/settings.gradle.kts | 26 + benchmarker/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 24 + benchmarker/ios/Flutter/Debug.xcconfig | 1 + benchmarker/ios/Flutter/Release.xcconfig | 1 + .../ios/Runner.xcodeproj/project.pbxproj | 644 ++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + benchmarker/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 +++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + benchmarker/ios/Runner/Info.plist | 75 ++ .../ios/Runner/Runner-Bridging-Header.h | 1 + benchmarker/ios/Runner/SceneDelegate.swift | 6 + benchmarker/ios/RunnerTests/RunnerTests.swift | 12 + benchmarker/lib/main.dart | 86 +++ .../lib/src/benchmark/benchmark_config.dart | 95 +++ .../src/benchmark/benchmark_controller.dart | 472 ++++++++++++ .../lib/src/benchmark/benchmark_log.dart | 28 + .../lib/src/benchmark/benchmark_stats.dart | 210 +++++ .../lib/src/benchmark/benchmark_worker.dart | 195 +++++ .../src/benchmark/http_client_builder.dart | 111 +++ .../lib/src/benchmark/worker_pool.dart | 140 ++++ .../lib/src/benchmark/worker_protocol.dart | 173 +++++ benchmarker/lib/src/ui/benchmark_page.dart | 147 ++++ .../src/ui/widgets/cache_progress_panel.dart | 77 ++ .../lib/src/ui/widgets/config_panel.dart | 220 ++++++ benchmarker/lib/src/ui/widgets/log_panel.dart | 106 +++ .../lib/src/ui/widgets/section_card.dart | 60 ++ .../lib/src/ui/widgets/stats_panel.dart | 270 +++++++ benchmarker/lib/src/util/formatting.dart | 52 ++ benchmarker/linux/.gitignore | 1 + benchmarker/linux/CMakeLists.txt | 128 +++ benchmarker/linux/flutter/CMakeLists.txt | 88 +++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 24 + benchmarker/linux/runner/CMakeLists.txt | 26 + benchmarker/linux/runner/main.cc | 6 + benchmarker/linux/runner/my_application.cc | 148 ++++ benchmarker/linux/runner/my_application.h | 21 + benchmarker/macos/.gitignore | 7 + .../macos/Flutter/Flutter-Debug.xcconfig | 1 + .../macos/Flutter/Flutter-Release.xcconfig | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 10 + .../macos/Runner.xcodeproj/project.pbxproj | 729 ++++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 117 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + benchmarker/macos/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 68 ++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 102994 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5680 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 520 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 14142 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1066 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 36406 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2218 bytes .../macos/Runner/Base.lproj/MainMenu.xib | 343 ++++++++ .../macos/Runner/Configs/AppInfo.xcconfig | 14 + .../macos/Runner/Configs/Debug.xcconfig | 2 + .../macos/Runner/Configs/Release.xcconfig | 2 + .../macos/Runner/Configs/Warnings.xcconfig | 13 + .../macos/Runner/DebugProfile.entitlements | 14 + benchmarker/macos/Runner/Info.plist | 37 + .../macos/Runner/MainFlutterWindow.swift | 15 + benchmarker/macos/Runner/Release.entitlements | 12 + .../macos/RunnerTests/RunnerTests.swift | 12 + benchmarker/pubspec.lock | 452 +++++++++++ benchmarker/pubspec.yaml | 27 + benchmarker/test/benchmark_config_test.dart | 98 +++ .../test/benchmark_controller_test.dart | 135 ++++ benchmarker/test/benchmark_stats_test.dart | 99 +++ benchmarker/test/worker_pool_test.dart | 138 ++++ benchmarker/windows/.gitignore | 17 + benchmarker/windows/CMakeLists.txt | 108 +++ benchmarker/windows/flutter/CMakeLists.txt | 109 +++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 24 + benchmarker/windows/runner/CMakeLists.txt | 40 + benchmarker/windows/runner/Runner.rc | 121 +++ benchmarker/windows/runner/flutter_window.cpp | 71 ++ benchmarker/windows/runner/flutter_window.h | 33 + benchmarker/windows/runner/main.cpp | 43 ++ benchmarker/windows/runner/resource.h | 16 + .../windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes .../windows/runner/runner.exe.manifest | 14 + benchmarker/windows/runner/utils.cpp | 69 ++ benchmarker/windows/runner/utils.h | 19 + benchmarker/windows/runner/win32_window.cpp | 288 +++++++ benchmarker/windows/runner/win32_window.h | 102 +++ 142 files changed, 8050 insertions(+) create mode 100644 benchmarker/.gitignore create mode 100644 benchmarker/.metadata create mode 100644 benchmarker/README.md create mode 100644 benchmarker/analysis_options.yaml create mode 100644 benchmarker/android/.gitignore create mode 100644 benchmarker/android/app/build.gradle.kts create mode 100644 benchmarker/android/app/src/debug/AndroidManifest.xml create mode 100644 benchmarker/android/app/src/main/AndroidManifest.xml create mode 100644 benchmarker/android/app/src/main/kotlin/com/httpcachestream/benchmarker/MainActivity.kt create mode 100644 benchmarker/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 benchmarker/android/app/src/main/res/drawable/launch_background.xml create mode 100644 benchmarker/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 benchmarker/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 benchmarker/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 benchmarker/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 benchmarker/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 benchmarker/android/app/src/main/res/values-night/styles.xml create mode 100644 benchmarker/android/app/src/main/res/values/styles.xml create mode 100644 benchmarker/android/app/src/main/res/xml/network_security_config.xml create mode 100644 benchmarker/android/app/src/profile/AndroidManifest.xml create mode 100644 benchmarker/android/build.gradle.kts create mode 100644 benchmarker/android/gradle.properties create mode 100644 benchmarker/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 benchmarker/android/settings.gradle.kts create mode 100644 benchmarker/ios/.gitignore create mode 100644 benchmarker/ios/Flutter/AppFrameworkInfo.plist create mode 100644 benchmarker/ios/Flutter/Debug.xcconfig create mode 100644 benchmarker/ios/Flutter/Release.xcconfig create mode 100644 benchmarker/ios/Runner.xcodeproj/project.pbxproj create mode 100644 benchmarker/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 benchmarker/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 benchmarker/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 benchmarker/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 benchmarker/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 benchmarker/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 benchmarker/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 benchmarker/ios/Runner/AppDelegate.swift create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 benchmarker/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 benchmarker/ios/Runner/Base.lproj/Main.storyboard create mode 100644 benchmarker/ios/Runner/Info.plist create mode 100644 benchmarker/ios/Runner/Runner-Bridging-Header.h create mode 100644 benchmarker/ios/Runner/SceneDelegate.swift create mode 100644 benchmarker/ios/RunnerTests/RunnerTests.swift create mode 100644 benchmarker/lib/main.dart create mode 100644 benchmarker/lib/src/benchmark/benchmark_config.dart create mode 100644 benchmarker/lib/src/benchmark/benchmark_controller.dart create mode 100644 benchmarker/lib/src/benchmark/benchmark_log.dart create mode 100644 benchmarker/lib/src/benchmark/benchmark_stats.dart create mode 100644 benchmarker/lib/src/benchmark/benchmark_worker.dart create mode 100644 benchmarker/lib/src/benchmark/http_client_builder.dart create mode 100644 benchmarker/lib/src/benchmark/worker_pool.dart create mode 100644 benchmarker/lib/src/benchmark/worker_protocol.dart create mode 100644 benchmarker/lib/src/ui/benchmark_page.dart create mode 100644 benchmarker/lib/src/ui/widgets/cache_progress_panel.dart create mode 100644 benchmarker/lib/src/ui/widgets/config_panel.dart create mode 100644 benchmarker/lib/src/ui/widgets/log_panel.dart create mode 100644 benchmarker/lib/src/ui/widgets/section_card.dart create mode 100644 benchmarker/lib/src/ui/widgets/stats_panel.dart create mode 100644 benchmarker/lib/src/util/formatting.dart create mode 100644 benchmarker/linux/.gitignore create mode 100644 benchmarker/linux/CMakeLists.txt create mode 100644 benchmarker/linux/flutter/CMakeLists.txt create mode 100644 benchmarker/linux/flutter/generated_plugin_registrant.cc create mode 100644 benchmarker/linux/flutter/generated_plugin_registrant.h create mode 100644 benchmarker/linux/flutter/generated_plugins.cmake create mode 100644 benchmarker/linux/runner/CMakeLists.txt create mode 100644 benchmarker/linux/runner/main.cc create mode 100644 benchmarker/linux/runner/my_application.cc create mode 100644 benchmarker/linux/runner/my_application.h create mode 100644 benchmarker/macos/.gitignore create mode 100644 benchmarker/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 benchmarker/macos/Flutter/Flutter-Release.xcconfig create mode 100644 benchmarker/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 benchmarker/macos/Runner.xcodeproj/project.pbxproj create mode 100644 benchmarker/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 benchmarker/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 benchmarker/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 benchmarker/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 benchmarker/macos/Runner/AppDelegate.swift create mode 100644 benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 benchmarker/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 benchmarker/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 benchmarker/macos/Runner/Configs/Debug.xcconfig create mode 100644 benchmarker/macos/Runner/Configs/Release.xcconfig create mode 100644 benchmarker/macos/Runner/Configs/Warnings.xcconfig create mode 100644 benchmarker/macos/Runner/DebugProfile.entitlements create mode 100644 benchmarker/macos/Runner/Info.plist create mode 100644 benchmarker/macos/Runner/MainFlutterWindow.swift create mode 100644 benchmarker/macos/Runner/Release.entitlements create mode 100644 benchmarker/macos/RunnerTests/RunnerTests.swift create mode 100644 benchmarker/pubspec.lock create mode 100644 benchmarker/pubspec.yaml create mode 100644 benchmarker/test/benchmark_config_test.dart create mode 100644 benchmarker/test/benchmark_controller_test.dart create mode 100644 benchmarker/test/benchmark_stats_test.dart create mode 100644 benchmarker/test/worker_pool_test.dart create mode 100644 benchmarker/windows/.gitignore create mode 100644 benchmarker/windows/CMakeLists.txt create mode 100644 benchmarker/windows/flutter/CMakeLists.txt create mode 100644 benchmarker/windows/flutter/generated_plugin_registrant.cc create mode 100644 benchmarker/windows/flutter/generated_plugin_registrant.h create mode 100644 benchmarker/windows/flutter/generated_plugins.cmake create mode 100644 benchmarker/windows/runner/CMakeLists.txt create mode 100644 benchmarker/windows/runner/Runner.rc create mode 100644 benchmarker/windows/runner/flutter_window.cpp create mode 100644 benchmarker/windows/runner/flutter_window.h create mode 100644 benchmarker/windows/runner/main.cpp create mode 100644 benchmarker/windows/runner/resource.h create mode 100644 benchmarker/windows/runner/resources/app_icon.ico create mode 100644 benchmarker/windows/runner/runner.exe.manifest create mode 100644 benchmarker/windows/runner/utils.cpp create mode 100644 benchmarker/windows/runner/utils.h create mode 100644 benchmarker/windows/runner/win32_window.cpp create mode 100644 benchmarker/windows/runner/win32_window.h diff --git a/benchmarker/.gitignore b/benchmarker/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/benchmarker/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/benchmarker/.metadata b/benchmarker/.metadata new file mode 100644 index 0000000..c17b941 --- /dev/null +++ b/benchmarker/.metadata @@ -0,0 +1,42 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: android + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: ios + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: linux + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: macos + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: windows + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/benchmarker/README.md b/benchmarker/README.md new file mode 100644 index 0000000..7a639b2 --- /dev/null +++ b/benchmarker/README.md @@ -0,0 +1,98 @@ +# http_cache_stream benchmarker + +A standalone Flutter app that benchmarks HTTP responses served by +`http_cache_stream`'s local cache server, and compares them against requests +that bypass the package entirely. + +It depends on the package by path (`http_cache_stream: {path: ../}`), so it +always benchmarks the working copy of the repository. + +Supported platforms: Android, iOS, Linux, macOS, Windows. Web is unsupported — +the benchmark relies on `dart:io` and `dart:isolate`. + +```bash +cd benchmarker +flutter run -d +``` + +## What it measures + +Requests are issued by a pool of **long-lived worker isolates**. Each isolate +builds exactly one `http.Client` when it starts and reuses it for every request, +so connection pools stay warm both within and between runs. The pool is only +respawned when the concurrency or the client implementation changes. + +### Inputs + +| Input | Meaning | +| --- | --- | +| Source URL | The remote URL under test. | +| Concurrency | Number of worker isolates. | +| Total requests | Requests issued in total, divided evenly between the workers (the remainder goes to the lowest-numbered workers). | + +### Run types + +| Type | Behavior | +| --- | --- | +| **Pre-cached** | The file is fully downloaded first, then every benchmarked request is served from the completed cache file. | +| **Non-cached** | The cache files are deleted first, so the requests race the cache download. | +| **Direct** | `http_cache_stream` is bypassed; workers request the source URL. | + +### HTTP client + +The client each worker uses is chosen from a registry of +[`HttpClientBuilder`](lib/src/benchmark/http_client_builder.dart)s — top-level +functions sent to the isolate at spawn time. Add an entry to +`kHttpClientOptions` to benchmark another implementation (for example +`cupertino_http` or `cronet_http`); nothing else needs to change. + +### Output + +Per run, aggregated from the results streamed back by the workers and refreshed +roughly four times a second: + +- Average, p50/p90/p99, min and max for **time to response headers**, **time to + first byte**, and **time to completion**. +- **Requests per second**, **bytes per second**, total bytes, average response + size, and elapsed wall-clock time. +- Outcome breakdown: verified, unverified (no `Content-Length`), byte + mismatches, HTTP errors, and failures. + +Every response's received byte count is checked against its `Content-Length`; a +mismatch is reported as a problem rather than a success. + +For cache-server runs, the page also shows live download progress +(`x / y bytes`, percentage) taken from `HttpCacheStream.cacheStateStream`, and +errors emitted by that stream are written to the log panel along with the run's +status lines. + +## Layout + +``` +lib/ + main.dart app entry; initializes HttpCacheManager + src/benchmark/ + benchmark_config.dart inputs, run types, request distribution + benchmark_controller.dart run orchestration and aggregation + benchmark_stats.dart timing/percentile accumulation + benchmark_worker.dart worker isolate entry point + http_client_builder.dart selectable http client implementations + worker_pool.dart long-lived isolate pool + worker_protocol.dart messages exchanged with the workers + src/ui/ page and panels +``` + +## Tests + +```bash +cd benchmarker +flutter test +``` + +The suite covers request distribution and statistics, drives real worker +isolates against a local origin server, and runs all three benchmark types +end-to-end through a real `HttpCacheManager`. + +> When testing against a **loopback** origin, address it as `localhost` rather +> than `127.0.0.1`: `http_cache_stream` rejects source URLs whose host matches +> the cache server's own host. diff --git a/benchmarker/analysis_options.yaml b/benchmarker/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/benchmarker/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/benchmarker/android/.gitignore b/benchmarker/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/benchmarker/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/benchmarker/android/app/build.gradle.kts b/benchmarker/android/app/build.gradle.kts new file mode 100644 index 0000000..e9bcc23 --- /dev/null +++ b/benchmarker/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.httpcachestream.benchmarker" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.httpcachestream.benchmarker" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/benchmarker/android/app/src/debug/AndroidManifest.xml b/benchmarker/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/benchmarker/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/benchmarker/android/app/src/main/AndroidManifest.xml b/benchmarker/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..251a53b --- /dev/null +++ b/benchmarker/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarker/android/app/src/main/kotlin/com/httpcachestream/benchmarker/MainActivity.kt b/benchmarker/android/app/src/main/kotlin/com/httpcachestream/benchmarker/MainActivity.kt new file mode 100644 index 0000000..6a8ddc5 --- /dev/null +++ b/benchmarker/android/app/src/main/kotlin/com/httpcachestream/benchmarker/MainActivity.kt @@ -0,0 +1,5 @@ +package com.httpcachestream.benchmarker + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/benchmarker/android/app/src/main/res/drawable-v21/launch_background.xml b/benchmarker/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/benchmarker/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/benchmarker/android/app/src/main/res/drawable/launch_background.xml b/benchmarker/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/benchmarker/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/benchmarker/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/benchmarker/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/benchmarker/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/benchmarker/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/benchmarker/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/benchmarker/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/benchmarker/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/benchmarker/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/benchmarker/android/app/src/main/res/values-night/styles.xml b/benchmarker/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/benchmarker/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/benchmarker/android/app/src/main/res/values/styles.xml b/benchmarker/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/benchmarker/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/benchmarker/android/app/src/main/res/xml/network_security_config.xml b/benchmarker/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..bc576aa --- /dev/null +++ b/benchmarker/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + 127.0.0.1 + localhost + + diff --git a/benchmarker/android/app/src/profile/AndroidManifest.xml b/benchmarker/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/benchmarker/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/benchmarker/android/build.gradle.kts b/benchmarker/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/benchmarker/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/benchmarker/android/gradle.properties b/benchmarker/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/benchmarker/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/benchmarker/android/gradle/wrapper/gradle-wrapper.properties b/benchmarker/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/benchmarker/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/benchmarker/android/settings.gradle.kts b/benchmarker/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/benchmarker/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/benchmarker/ios/.gitignore b/benchmarker/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/benchmarker/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/benchmarker/ios/Flutter/AppFrameworkInfo.plist b/benchmarker/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/benchmarker/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/benchmarker/ios/Flutter/Debug.xcconfig b/benchmarker/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/benchmarker/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/benchmarker/ios/Flutter/Release.xcconfig b/benchmarker/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/benchmarker/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/benchmarker/ios/Runner.xcodeproj/project.pbxproj b/benchmarker/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..59f669f --- /dev/null +++ b/benchmarker/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/benchmarker/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/benchmarker/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/benchmarker/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/benchmarker/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarker/ios/Runner.xcworkspace/contents.xcworkspacedata b/benchmarker/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/benchmarker/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/benchmarker/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/benchmarker/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/benchmarker/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/benchmarker/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/benchmarker/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/benchmarker/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/benchmarker/ios/Runner/AppDelegate.swift b/benchmarker/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/benchmarker/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/benchmarker/ios/Runner/Base.lproj/LaunchScreen.storyboard b/benchmarker/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/benchmarker/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarker/ios/Runner/Base.lproj/Main.storyboard b/benchmarker/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/benchmarker/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarker/ios/Runner/Info.plist b/benchmarker/ios/Runner/Info.plist new file mode 100644 index 0000000..72b4840 --- /dev/null +++ b/benchmarker/ios/Runner/Info.plist @@ -0,0 +1,75 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Benchmarker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + benchmarker + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/benchmarker/ios/Runner/Runner-Bridging-Header.h b/benchmarker/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/benchmarker/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/benchmarker/ios/Runner/SceneDelegate.swift b/benchmarker/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/benchmarker/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/benchmarker/ios/RunnerTests/RunnerTests.swift b/benchmarker/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/benchmarker/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/benchmarker/lib/main.dart b/benchmarker/lib/main.dart new file mode 100644 index 0000000..fc42e6e --- /dev/null +++ b/benchmarker/lib/main.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:http_cache_stream/http_cache_stream.dart'; + +import 'src/ui/benchmark_page.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const BenchmarkerApp()); +} + +class BenchmarkerApp extends StatelessWidget { + const BenchmarkerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'http_cache_stream benchmarker', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorSchemeSeed: Colors.indigo, + brightness: Brightness.light, + ), + darkTheme: ThemeData( + colorSchemeSeed: Colors.indigo, + brightness: Brightness.dark, + ), + home: const _CacheManagerBootstrap(child: BenchmarkPage()), + ); + } +} + +/// Initializes [HttpCacheManager] before the benchmark page is shown. +class _CacheManagerBootstrap extends StatefulWidget { + const _CacheManagerBootstrap({required this.child}); + + final Widget child; + + @override + State<_CacheManagerBootstrap> createState() => _CacheManagerBootstrapState(); +} + +class _CacheManagerBootstrapState extends State<_CacheManagerBootstrap> { + late Future _init = HttpCacheManager.init(); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _init, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Failed to initialize HttpCacheManager:\n' + '${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => + setState(() => _init = HttpCacheManager.init()), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + if (!snapshot.hasData) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + return widget.child; + }, + ); + } +} diff --git a/benchmarker/lib/src/benchmark/benchmark_config.dart b/benchmarker/lib/src/benchmark/benchmark_config.dart new file mode 100644 index 0000000..c8f580d --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_config.dart @@ -0,0 +1,95 @@ +import 'http_client_builder.dart'; + +/// What the benchmark measures. +enum BenchmarkType { + /// The file is fully downloaded before the run starts, so every request is + /// served from the completed cache file. + preCached( + 'Pre-cached', + 'Fully pre-cache the file, then benchmark the cache server serving it from disk.', + ), + + /// The cache is wiped before the run, so requests race the cache download. + nonCached( + 'Non-cached', + 'Wipe the cache first; requests are served while the source is still downloading.', + ), + + /// http_cache_stream is bypassed entirely. + direct( + 'Direct', + 'Bypass http_cache_stream and request the source URL directly.', + ); + + const BenchmarkType(this.label, this.description); + + final String label; + final String description; + + /// Whether this type routes requests through the local cache server. + bool get usesCacheServer => this != BenchmarkType.direct; +} + +/// A single benchmark run's inputs. +class BenchmarkConfig { + const BenchmarkConfig({ + required this.sourceUrl, + required this.concurrency, + required this.totalRequests, + required this.type, + required this.clientOption, + }); + + /// The remote URL under test. + final Uri sourceUrl; + + /// Number of worker isolates. Requests are divided between them. + final int concurrency; + + /// Total requests to issue across all workers. + final int totalRequests; + + final BenchmarkType type; + + /// Which [HttpClientBuilder] the workers use. + final HttpClientOption clientOption; + + /// Splits [totalRequests] across [concurrency] workers as evenly as possible. + /// + /// The remainder is handed to the lowest-numbered workers, so the returned + /// counts differ by at most one and always sum to [totalRequests]. + List requestDistribution() { + final base = totalRequests ~/ concurrency; + final remainder = totalRequests % concurrency; + return List.generate( + concurrency, + (index) => base + (index < remainder ? 1 : 0), + ); + } + + /// Returns a human-readable validation error, or null when the config is + /// runnable. + static String? validate({ + required String url, + required int? concurrency, + required int? totalRequests, + }) { + final uri = Uri.tryParse(url.trim()); + if (url.trim().isEmpty || uri == null || !uri.hasScheme || uri.host.isEmpty) { + return 'Enter a valid absolute source URL.'; + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + return 'Source URL must use http or https.'; + } + if (concurrency == null || concurrency < 1) { + return 'Concurrency must be at least 1.'; + } + if (totalRequests == null || totalRequests < 1) { + return 'Total requests must be at least 1.'; + } + if (totalRequests < concurrency) { + return 'Total requests must be at least the concurrency ($concurrency).'; + } + return null; + } +} diff --git a/benchmarker/lib/src/benchmark/benchmark_controller.dart b/benchmarker/lib/src/benchmark/benchmark_controller.dart new file mode 100644 index 0000000..0e50bea --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_controller.dart @@ -0,0 +1,472 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:http_cache_stream/http_cache_stream.dart'; + +import '../util/formatting.dart'; +import 'benchmark_config.dart'; +import 'benchmark_log.dart'; +import 'benchmark_stats.dart'; +import 'worker_pool.dart'; +import 'worker_protocol.dart'; + +enum BenchmarkPhase { + idle, + preparing, + running, + cancelling, + finished, + cancelled, + failed; + + bool get isBusy => + this == BenchmarkPhase.preparing || + this == BenchmarkPhase.running || + this == BenchmarkPhase.cancelling; +} + +/// Drives a benchmark run: prepares the cache, dispatches work to the isolate +/// pool, aggregates the results, and exposes everything the UI renders. +class BenchmarkController extends ChangeNotifier { + /// How often the UI is refreshed while a run is in progress. + static const Duration refreshInterval = Duration(milliseconds: 250); + + /// Maximum number of log lines retained. + static const int maxLogEntries = 500; + + final List _logs = []; + final Map _problemCounts = {}; + final Stopwatch _runClock = Stopwatch(); + + WorkerPool? _pool; + StreamSubscription? _poolEvents; + HttpCacheStream? _cacheStream; + StreamSubscription? _cacheStateEvents; + StatsAccumulator? _accumulator; + Completer? _runCompleter; + Timer? _ticker; + + final Set _outstandingWorkers = {}; + int _jobId = 0; + bool _cancelRequested = false; + bool _dirty = false; + bool _warnedUnverified = false; + bool _disposed = false; + + BenchmarkPhase _phase = BenchmarkPhase.idle; + BenchmarkConfig? _config; + BenchmarkStats? _stats; + CacheState? _cacheState; + Uri? _targetUrl; + + BenchmarkPhase get phase => _phase; + + /// The config of the current or most recent run. + BenchmarkConfig? get config => _config; + + /// The latest statistics snapshot, or null before the first run. + BenchmarkStats? get stats => _stats; + + /// Latest cache state for the URL under test. Null for direct runs. + CacheState? get cacheState => _cacheState; + + /// The URL the workers are hitting: the cache URL, or the source URL for + /// direct runs. + Uri? get targetUrl => _targetUrl; + + /// Whether cache progress applies to the current run. + bool get showsCacheProgress => _config?.type.usesCacheServer ?? false; + + List get logs => List.unmodifiable(_logs); + + /// Number of live worker isolates, or 0 when no pool is spawned. + int get poolSize => _pool?.size ?? 0; + + /// Starts a run. Does nothing when a run is already in progress. + Future start(BenchmarkConfig config) async { + if (_phase.isBusy || _disposed) return; + + _config = config; + _cancelRequested = false; + _warnedUnverified = false; + _problemCounts.clear(); + _accumulator = StatsAccumulator(config.totalRequests); + _stats = BenchmarkStats.empty(config.totalRequests); + _cacheState = null; + _targetUrl = null; + _runClock + ..reset() + ..stop(); + + _log('── ${config.type.label} run: ${config.sourceUrl}'); + _log( + '${config.totalRequests} requests · ${config.concurrency} workers · ' + '${config.clientOption.label}', + ); + + _setPhase(BenchmarkPhase.preparing); + _startTicker(); + + try { + final target = _targetUrl = await _prepare(config); + if (_cancelRequested) { + _completeRun(BenchmarkPhase.cancelled); + return; + } + + await _ensurePool(config); + if (_cancelRequested) { + _completeRun(BenchmarkPhase.cancelled); + return; + } + + _log('Benchmarking $target'); + _setPhase(BenchmarkPhase.running); + _runClock + ..reset() + ..start(); + _dispatch(config, target); + + await _runCompleter!.future; + _runClock.stop(); + _completeRun( + _cancelRequested ? BenchmarkPhase.cancelled : BenchmarkPhase.finished, + ); + } catch (error, stack) { + _runClock.stop(); + _log('Run failed: $error', level: LogLevel.error); + debugPrint('Benchmark run failed: $error\n$stack'); + _completeRun(BenchmarkPhase.failed); + } + } + + /// Requests cancellation of the active run. + Future cancel() async { + if (!_phase.isBusy || _cancelRequested) return; + _cancelRequested = true; + _log('Cancelling…', level: LogLevel.warning); + + if (_phase == BenchmarkPhase.running) { + _setPhase(BenchmarkPhase.cancelling); + _pool?.broadcast(const CancelJobCommand()); + } else { + // Still preparing: tearing the stream down aborts an in-flight pre-cache. + _setPhase(BenchmarkPhase.cancelling); + await _cacheStream?.dispose(force: true).timeout( + const Duration(seconds: 5), + onTimeout: () {}, + ); + } + } + + /// Clears the log view. + void clearLogs() { + _logs.clear(); + notifyListeners(); + } + + // --------------------------------------------------------------------------- + // Run lifecycle + // --------------------------------------------------------------------------- + + /// Resolves the URL the workers should hit, preparing the cache as the + /// benchmark type requires. + Future _prepare(BenchmarkConfig config) async { + if (config.type == BenchmarkType.direct) { + _log('Direct mode: http_cache_stream is bypassed.'); + return config.sourceUrl; + } + + final manager = HttpCacheManager.instance; + await _teardownCacheStream(); + + if (config.type == BenchmarkType.nonCached) { + final existing = manager.getExistingStream(config.sourceUrl); + if (existing != null) { + await existing.dispose(force: true).timeout( + const Duration(seconds: 5), + onTimeout: () {}, + ); + } + final deleted = await manager.getCacheFiles(config.sourceUrl).delete(); + _log(deleted ? 'Cache wiped.' : 'No cache files to wipe.'); + } + + final stream = manager.createStream(config.sourceUrl); + _cacheStream = stream; + _watchCacheStream(stream); + _log('Cache URL: ${stream.cacheUrl}'); + + if (config.type == BenchmarkType.preCached) { + _log('Pre-caching source…'); + final file = await stream.download(); + final length = await file.length(); + _log( + 'Pre-cache complete: ${formatBytes(length)} ($length bytes)', + level: LogLevel.success, + ); + } + + return stream.cacheUrl; + } + + /// Spawns the worker pool, reusing the existing one when it already matches + /// the requested concurrency and client implementation. + Future _ensurePool(BenchmarkConfig config) async { + final pool = _pool; + if (pool != null && + pool.size == config.concurrency && + pool.clientId == config.clientOption.id) { + _log('Reusing ${pool.size} warm worker isolates.'); + return; + } + + if (pool != null) { + await _poolEvents?.cancel(); + _poolEvents = null; + _pool = null; + await pool.dispose(); + } + + _log( + 'Spawning ${config.concurrency} worker isolates ' + '(${config.clientOption.label})…', + ); + final newPool = await WorkerPool.spawn( + size: config.concurrency, + clientOption: config.clientOption, + ); + _poolEvents = newPool.events.listen(_onWorkerEvent); + _pool = newPool; + _log('Worker pool ready.'); + } + + /// Divides the requests between workers and starts them. + void _dispatch(BenchmarkConfig config, Uri target) { + final pool = _pool!; + final distribution = config.requestDistribution(); + final jobId = ++_jobId; + + _outstandingWorkers + ..clear() + ..addAll( + List.generate(config.concurrency, (index) => index) + .where((index) => distribution[index] > 0), + ); + _runCompleter = Completer(); + + var sequence = 0; + for (var workerId = 0; workerId < config.concurrency; workerId++) { + final count = distribution[workerId]; + if (count == 0) continue; + pool.send( + workerId, + RunJobCommand( + jobId: jobId, + url: target.toString(), + requestCount: count, + firstSequence: sequence, + ), + ); + sequence += count; + } + + _log( + 'Dispatched ${config.totalRequests} requests across ' + '${_outstandingWorkers.length} worker(s): [${distribution.join(', ')}].', + ); + + if (_outstandingWorkers.isEmpty && !_runCompleter!.isCompleted) { + _runCompleter!.complete(); + } + } + + void _completeRun(BenchmarkPhase phase) { + _stopTicker(); + _refreshStats(); + _summarize(phase); + unawaited(_releaseCacheStream()); + _setPhase(phase); + } + + void _summarize(BenchmarkPhase phase) { + final stats = _stats; + if (stats == null) return; + + switch (phase) { + case BenchmarkPhase.finished: + _log( + 'Run complete: ${stats.completed}/${stats.totalRequests} requests in ' + '${formatDuration(stats.elapsed)} · ' + '${formatRate(stats.requestsPerSecond, 'req/s')} · ' + '${formatBytesPerSecond(stats.bytesPerSecond)}', + level: stats.errorCount == 0 ? LogLevel.success : LogLevel.warning, + ); + case BenchmarkPhase.cancelled: + _log( + 'Run cancelled after ${stats.completed} requests.', + level: LogLevel.warning, + ); + default: + break; + } + + if (stats.errorCount > 0) { + _log( + '${stats.errorCount} problem request(s): ' + '${stats.lengthMismatches} byte mismatch, ${stats.httpErrors} HTTP ' + 'error, ${stats.failures} failure.', + level: LogLevel.error, + ); + for (final entry in _problemCounts.entries) { + _log(' ×${entry.value} ${entry.key}', level: LogLevel.error); + } + } else if (stats.completed > 0) { + _log( + 'All ${stats.completed} responses matched their Content-Length.', + level: LogLevel.success, + ); + } + } + + // --------------------------------------------------------------------------- + // Worker events + // --------------------------------------------------------------------------- + + void _onWorkerEvent(WorkerEvent event) { + switch (event) { + case ResultBatchEvent(:final results): + final accumulator = _accumulator; + if (accumulator == null) return; + for (final result in results) { + accumulator.add(result); + _noteResult(result); + } + _dirty = true; + case WorkerLogEvent(:final workerId, :final message, :final isError): + _log( + 'Worker $workerId: $message', + level: isError ? LogLevel.error : LogLevel.info, + ); + case JobDoneEvent(:final workerId, :final jobId): + if (jobId != _jobId) return; + _outstandingWorkers.remove(workerId); + if (_outstandingWorkers.isEmpty && + _runCompleter != null && + !_runCompleter!.isCompleted) { + _runCompleter!.complete(); + } + case WorkerFatalEvent(:final workerId, :final message): + _log('Worker $workerId fatal: $message', level: LogLevel.error); + case WorkerReadyEvent(): + break; + } + } + + /// Logs problems without flooding the log: the first occurrence of each + /// distinct problem is logged, then only at power-of-ten milestones. + void _noteResult(RequestResult result) { + if (result.outcome == RequestOutcome.unverified && !_warnedUnverified) { + _warnedUnverified = true; + _log( + 'Response has no Content-Length; byte counts cannot be verified.', + level: LogLevel.warning, + ); + return; + } + if (result.isSuccess) return; + + final key = result.describeProblem(); + final count = (_problemCounts[key] ?? 0) + 1; + _problemCounts[key] = count; + if (count == 1) { + _log( + 'Request #${result.sequence} (worker ${result.workerId}): $key', + level: LogLevel.error, + ); + } else if (count == 10 || count == 100 || count == 1000) { + _log('$key — ×$count', level: LogLevel.error); + } + } + + // --------------------------------------------------------------------------- + // Cache stream + // --------------------------------------------------------------------------- + + void _watchCacheStream(HttpCacheStream stream) { + _cacheStateEvents = stream.cacheStateStream.listen( + (state) { + _cacheState = state; + _dirty = true; + }, + onError: (Object error) { + _log('Cache stream error: $error', level: LogLevel.error); + }, + cancelOnError: false, + ); + } + + Future _releaseCacheStream() async { + final stream = _cacheStream; + if (stream == null) return; + await _cacheStateEvents?.cancel(); + _cacheStateEvents = null; + _cacheStream = null; + // Matches the retain taken by createStream; the local server may still hold + // retains from in-flight requests, which release on their own. + await stream.dispose().timeout( + const Duration(seconds: 5), + onTimeout: () {}, + ); + } + + Future _teardownCacheStream() => _releaseCacheStream(); + + // --------------------------------------------------------------------------- + // UI refresh + // --------------------------------------------------------------------------- + + void _startTicker() { + _ticker?.cancel(); + _ticker = Timer.periodic(refreshInterval, (_) { + if (_dirty || _runClock.isRunning) _refreshStats(); + }); + } + + void _stopTicker() { + _ticker?.cancel(); + _ticker = null; + } + + void _refreshStats() { + final accumulator = _accumulator; + if (accumulator == null) return; + _dirty = false; + _stats = accumulator.snapshot(_runClock.elapsed); + notifyListeners(); + } + + void _setPhase(BenchmarkPhase phase) { + _phase = phase; + notifyListeners(); + } + + void _log(String message, {LogLevel level = LogLevel.info}) { + _logs.add(LogEntry(message, level: level)); + if (_logs.length > maxLogEntries) { + _logs.removeRange(0, _logs.length - maxLogEntries); + } + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _stopTicker(); + unawaited(_poolEvents?.cancel()); + unawaited(_pool?.dispose()); + unawaited(_cacheStateEvents?.cancel()); + unawaited(_cacheStream?.dispose()); + super.dispose(); + } +} diff --git a/benchmarker/lib/src/benchmark/benchmark_log.dart b/benchmarker/lib/src/benchmark/benchmark_log.dart new file mode 100644 index 0000000..ba431ae --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_log.dart @@ -0,0 +1,28 @@ +import '../util/formatting.dart'; + +enum LogLevel { info, success, warning, error } + +/// A single line in the log/status view. +class LogEntry { + LogEntry(this.message, {this.level = LogLevel.info}) : time = DateTime.now(); + + final DateTime time; + final String message; + final LogLevel level; + + String get prefix { + switch (level) { + case LogLevel.info: + return ''; + case LogLevel.success: + return 'OK '; + case LogLevel.warning: + return 'WARN '; + case LogLevel.error: + return 'ERROR '; + } + } + + @override + String toString() => '[${formatClockTime(time)}] $prefix$message'; +} diff --git a/benchmarker/lib/src/benchmark/benchmark_stats.dart b/benchmarker/lib/src/benchmark/benchmark_stats.dart new file mode 100644 index 0000000..79b927b --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_stats.dart @@ -0,0 +1,210 @@ +import 'worker_protocol.dart'; + +/// Summary of a single timing series, in microseconds. +class TimingStats { + const TimingStats({ + required this.count, + required this.avgMicros, + required this.minMicros, + required this.maxMicros, + required this.p50Micros, + required this.p90Micros, + required this.p99Micros, + }); + + final int count; + final double avgMicros; + final int minMicros; + final int maxMicros; + final int p50Micros; + final int p90Micros; + final int p99Micros; + + Duration get avg => Duration(microseconds: avgMicros.round()); + Duration get min => Duration(microseconds: minMicros); + Duration get max => Duration(microseconds: maxMicros); + Duration get p50 => Duration(microseconds: p50Micros); + Duration get p90 => Duration(microseconds: p90Micros); + Duration get p99 => Duration(microseconds: p99Micros); + + /// Builds a summary from an unsorted list of microsecond samples. + /// Returns null when there are no samples. + static TimingStats? fromSamples(List samples) { + if (samples.isEmpty) return null; + final sorted = List.of(samples)..sort(); + var sum = 0; + for (final value in sorted) { + sum += value; + } + return TimingStats( + count: sorted.length, + avgMicros: sum / sorted.length, + minMicros: sorted.first, + maxMicros: sorted.last, + p50Micros: _percentile(sorted, 0.50), + p90Micros: _percentile(sorted, 0.90), + p99Micros: _percentile(sorted, 0.99), + ); + } + + static int _percentile(List sorted, double fraction) { + final index = ((sorted.length - 1) * fraction).round(); + return sorted[index.clamp(0, sorted.length - 1)]; + } +} + +/// An immutable snapshot of a benchmark run, safe to hand to the UI. +class BenchmarkStats { + const BenchmarkStats({ + required this.totalRequests, + required this.completed, + required this.succeeded, + required this.lengthMismatches, + required this.unverified, + required this.httpErrors, + required this.failures, + required this.totalBytes, + required this.elapsed, + required this.headerTime, + required this.firstByteTime, + required this.completionTime, + }); + + const BenchmarkStats.empty(this.totalRequests) + : completed = 0, + succeeded = 0, + lengthMismatches = 0, + unverified = 0, + httpErrors = 0, + failures = 0, + totalBytes = 0, + elapsed = Duration.zero, + headerTime = null, + firstByteTime = null, + completionTime = null; + + /// Requests the run was configured to issue. + final int totalRequests; + + /// Requests that have reported a result so far. + final int completed; + + /// Requests that returned 2xx with a byte count matching `Content-Length`. + final int succeeded; + + /// Requests that returned 2xx but whose byte count did not match. + final int lengthMismatches; + + /// Requests that returned 2xx without a `Content-Length` to verify against. + final int unverified; + + /// Requests that completed with a non-2xx status. + final int httpErrors; + + /// Requests that threw before completing. + final int failures; + + /// Total response body bytes received. + final int totalBytes; + + /// Wall-clock time since the run started. + final Duration elapsed; + + /// Time until the response headers were available. + final TimingStats? headerTime; + + /// Time until the first response body byte arrived. + final TimingStats? firstByteTime; + + /// Time until the response body was fully read. + final TimingStats? completionTime; + + /// Requests that neither succeeded nor were merely unverified. + int get errorCount => lengthMismatches + httpErrors + failures; + + double get progress => + totalRequests == 0 ? 0 : (completed / totalRequests).clamp(0.0, 1.0); + + /// Completed requests per second of wall-clock time. + double get requestsPerSecond { + final seconds = elapsed.inMicroseconds / Duration.microsecondsPerSecond; + if (seconds <= 0) return 0; + return completed / seconds; + } + + /// Aggregate throughput across all workers. + double get bytesPerSecond { + final seconds = elapsed.inMicroseconds / Duration.microsecondsPerSecond; + if (seconds <= 0) return 0; + return totalBytes / seconds; + } + + /// Mean response size. + double get avgBytesPerRequest => completed == 0 ? 0 : totalBytes / completed; +} + +/// Accumulates [RequestResult]s streamed back from the workers and produces +/// [BenchmarkStats] snapshots on demand. +class StatsAccumulator { + StatsAccumulator(this.totalRequests); + + final int totalRequests; + + final List _headerMicros = []; + final List _firstByteMicros = []; + final List _completionMicros = []; + + int _completed = 0; + int _succeeded = 0; + int _lengthMismatches = 0; + int _unverified = 0; + int _httpErrors = 0; + int _failures = 0; + int _totalBytes = 0; + + int get completed => _completed; + + void add(RequestResult result) { + _completed++; + _totalBytes += result.bytesReceived; + + switch (result.outcome) { + case RequestOutcome.success: + _succeeded++; + case RequestOutcome.lengthMismatch: + _lengthMismatches++; + case RequestOutcome.unverified: + _unverified++; + case RequestOutcome.httpError: + _httpErrors++; + case RequestOutcome.failure: + _failures++; + } + + // Timings are only meaningful for requests that actually delivered a + // response, so failed requests are excluded from the latency series. + if (result.outcome == RequestOutcome.failure) return; + if (result.headerMicros case final micros?) _headerMicros.add(micros); + if (result.firstByteMicros case final micros?) { + _firstByteMicros.add(micros); + } + _completionMicros.add(result.totalMicros); + } + + BenchmarkStats snapshot(Duration elapsed) { + return BenchmarkStats( + totalRequests: totalRequests, + completed: _completed, + succeeded: _succeeded, + lengthMismatches: _lengthMismatches, + unverified: _unverified, + httpErrors: _httpErrors, + failures: _failures, + totalBytes: _totalBytes, + elapsed: elapsed, + headerTime: TimingStats.fromSamples(_headerMicros), + firstByteTime: TimingStats.fromSamples(_firstByteMicros), + completionTime: TimingStats.fromSamples(_completionMicros), + ); + } +} diff --git a/benchmarker/lib/src/benchmark/benchmark_worker.dart b/benchmarker/lib/src/benchmark/benchmark_worker.dart new file mode 100644 index 0000000..949d63e --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_worker.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:http/http.dart' as http; + +import 'worker_protocol.dart'; + +/// Entry point of a benchmark worker isolate. +/// +/// The isolate is long-lived: it builds one [http.Client] on startup, keeps it +/// for the lifetime of the isolate, and processes jobs until it is told to +/// shut down. +Future benchmarkWorkerMain(WorkerBootstrap bootstrap) async { + final worker = _BenchmarkWorker(bootstrap); + await worker.serve(); +} + +/// How many results are buffered before being sent to the main isolate. +const int _maxBatchSize = 32; + +/// How long results are buffered before being sent to the main isolate. +const Duration _maxBatchAge = Duration(milliseconds: 100); + +class _BenchmarkWorker { + _BenchmarkWorker(this._bootstrap); + + final WorkerBootstrap _bootstrap; + final ReceivePort _commandPort = ReceivePort(); + final List _pending = []; + final Stopwatch _batchClock = Stopwatch(); + + late final http.Client _client; + final Completer _shutdown = Completer(); + bool _cancelRequested = false; + bool _busy = false; + + int get _id => _bootstrap.workerId; + + Future serve() async { + try { + _client = _bootstrap.clientBuilder(); + } catch (e) { + _send(WorkerFatalEvent(_id, 'Failed to build http client: $e')); + _commandPort.close(); + return; + } + + _commandPort.listen(_onCommand); + _send(WorkerReadyEvent(_id, _commandPort.sendPort)); + + await _shutdown.future; + + try { + _client.close(); + } catch (_) { + // The client is being torn down; nothing useful to report. + } + _commandPort.close(); + } + + void _onCommand(Object? message) { + switch (message) { + case RunJobCommand job: + if (_busy) { + _send( + WorkerLogEvent( + _id, + 'Worker $_id received a job while still running one; ignored.', + isError: true, + ), + ); + return; + } + unawaited(_runJob(job)); + case CancelJobCommand(): + _cancelRequested = true; + case ShutdownCommand(): + _cancelRequested = true; + if (!_shutdown.isCompleted) _shutdown.complete(); + default: + _send( + WorkerLogEvent( + _id, + 'Worker $_id received unknown message: $message', + isError: true, + ), + ); + } + } + + Future _runJob(RunJobCommand job) async { + _busy = true; + _cancelRequested = false; + _batchClock + ..reset() + ..start(); + + final uri = Uri.parse(job.url); + try { + for (var i = 0; i < job.requestCount; i++) { + if (_cancelRequested) break; + final result = await _executeRequest(uri, job.firstSequence + i); + if (result == null) break; // Abandoned mid-response by a cancel. + _pending.add(result); + if (_pending.length >= _maxBatchSize || + _batchClock.elapsed >= _maxBatchAge) { + _flush(); + } + } + } catch (e, stack) { + _send(WorkerFatalEvent(_id, 'Worker $_id job failed: $e\n$stack')); + } finally { + _flush(); + _batchClock.stop(); + _busy = false; + _send(JobDoneEvent(_id, job.jobId, cancelled: _cancelRequested)); + } + } + + /// Issues one GET and measures header time, first-byte time, and completion + /// time, verifying the received byte count against `Content-Length`. + /// + /// Returns null if the request was abandoned by a cancel before the response + /// was fully read; such a request is not a measurement and is not reported. + Future _executeRequest(Uri uri, int sequence) async { + final stopwatch = Stopwatch()..start(); + int? headerMicros; + int? firstByteMicros; + var bytesReceived = 0; + + try { + final response = await _client.send(http.Request('GET', uri)); + headerMicros = stopwatch.elapsedMicroseconds; + + await for (final chunk in response.stream) { + if (chunk.isNotEmpty) { + firstByteMicros ??= stopwatch.elapsedMicroseconds; + bytesReceived += chunk.length; + } + // Breaking cancels the subscription, which closes the connection. + if (_cancelRequested) return null; + } + stopwatch.stop(); + + final contentLength = response.contentLength; + final RequestOutcome outcome; + if (response.statusCode < 200 || response.statusCode >= 300) { + outcome = RequestOutcome.httpError; + } else if (contentLength == null) { + outcome = RequestOutcome.unverified; + } else if (contentLength != bytesReceived) { + outcome = RequestOutcome.lengthMismatch; + } else { + outcome = RequestOutcome.success; + } + + return RequestResult( + workerId: _id, + sequence: sequence, + outcome: outcome, + headerMicros: headerMicros, + firstByteMicros: firstByteMicros, + totalMicros: stopwatch.elapsedMicroseconds, + bytesReceived: bytesReceived, + contentLength: contentLength, + statusCode: response.statusCode, + ); + } catch (e) { + stopwatch.stop(); + // A cancel tears down in-flight connections; that is not a real failure. + if (_cancelRequested) return null; + return RequestResult( + workerId: _id, + sequence: sequence, + outcome: RequestOutcome.failure, + headerMicros: headerMicros, + firstByteMicros: firstByteMicros, + totalMicros: stopwatch.elapsedMicroseconds, + bytesReceived: bytesReceived, + error: '$e', + ); + } + } + + void _flush() { + if (_pending.isEmpty) return; + _send(ResultBatchEvent(_id, List.of(_pending))); + _pending.clear(); + _batchClock + ..reset() + ..start(); + } + + void _send(WorkerEvent event) => _bootstrap.mainPort.send(event); +} diff --git a/benchmarker/lib/src/benchmark/http_client_builder.dart b/benchmarker/lib/src/benchmark/http_client_builder.dart new file mode 100644 index 0000000..168b21d --- /dev/null +++ b/benchmarker/lib/src/benchmark/http_client_builder.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + +/// Builds the [http.Client] used by a single worker isolate. +/// +/// Each worker isolate builds exactly one client when it starts and reuses it +/// for every request it issues, so connection pooling and keep-alive behavior +/// belongs to the client returned here. +/// +/// A builder must be a top-level or static function: it is sent across a +/// [SendPort] when the worker isolate is spawned. +typedef HttpClientBuilder = http.Client Function(); + +/// A selectable [HttpClientBuilder], shown in the benchmark configuration UI. +/// +/// Add an entry here to benchmark another client implementation (for example +/// `cupertino_http` or `cronet_http`); nothing else needs to change. +class HttpClientOption { + const HttpClientOption({ + required this.id, + required this.label, + required this.description, + required this.builder, + }); + + /// Stable identifier, used to detect when the worker pool must be respawned. + final String id; + final String label; + final String description; + final HttpClientBuilder builder; + + @override + String toString() => label; +} + +/// The default `package:http` client. On the Dart VM this is an [IOClient] +/// wrapping a stock [HttpClient]. +http.Client buildDefaultClient() => http.Client(); + +/// A `dart:io` client with a raised per-host connection limit. Useful when a +/// single isolate is expected to hold more than one socket open at a time. +http.Client buildPooledIoClient() { + final httpClient = HttpClient() + ..maxConnectionsPerHost = 64 + ..idleTimeout = const Duration(seconds: 30); + return IOClient(httpClient); +} + +/// A `dart:io` client that opens a fresh connection per request, which isolates +/// connection setup cost from the rest of the response timings. +http.Client buildNoKeepAliveClient() => _NoKeepAliveClient(HttpClient()); + +/// A `dart:io` client that does not transparently decompress responses, so the +/// byte counts reported by the benchmark match the bytes on the wire. +http.Client buildRawIoClient() { + final httpClient = HttpClient()..autoUncompress = false; + return IOClient(httpClient); +} + +/// All client implementations selectable from the UI. +const List kHttpClientOptions = [ + HttpClientOption( + id: 'default', + label: 'package:http (default)', + description: 'Stock http.Client, keep-alive enabled.', + builder: buildDefaultClient, + ), + HttpClientOption( + id: 'io-pooled', + label: 'dart:io (pooled)', + description: 'IOClient, 64 connections per host, 30s idle timeout.', + builder: buildPooledIoClient, + ), + HttpClientOption( + id: 'io-no-keep-alive', + label: 'dart:io (no keep-alive)', + description: 'IOClient, a new connection for every request.', + builder: buildNoKeepAliveClient, + ), + HttpClientOption( + id: 'io-raw', + label: 'dart:io (no auto-uncompress)', + description: 'IOClient, compressed responses are counted as received.', + builder: buildRawIoClient, + ), +]; + +HttpClientOption clientOptionById(String id) { + return kHttpClientOptions.firstWhere( + (option) => option.id == id, + orElse: () => kHttpClientOptions.first, + ); +} + +/// Forces `Connection: close` on every request sent through the delegate. +class _NoKeepAliveClient extends http.BaseClient { + _NoKeepAliveClient(HttpClient httpClient) : _inner = IOClient(httpClient); + + final IOClient _inner; + + @override + Future send(http.BaseRequest request) { + request.persistentConnection = false; + return _inner.send(request); + } + + @override + void close() => _inner.close(); +} diff --git a/benchmarker/lib/src/benchmark/worker_pool.dart b/benchmarker/lib/src/benchmark/worker_pool.dart new file mode 100644 index 0000000..ad4b1a9 --- /dev/null +++ b/benchmarker/lib/src/benchmark/worker_pool.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'benchmark_worker.dart'; +import 'http_client_builder.dart'; +import 'worker_protocol.dart'; + +/// A pool of long-lived worker isolates. +/// +/// Isolates are spawned once and reused across benchmark runs: each holds a +/// single `http.Client` built by the configured [HttpClientBuilder], so +/// connection pools stay warm between runs. The pool is only respawned when the +/// worker count or the client implementation changes. +class WorkerPool { + WorkerPool._(this.size, this.clientId); + + /// Number of worker isolates in this pool. + final int size; + + /// Identifier of the [HttpClientOption] the workers were built with. + final String clientId; + + final ReceivePort _eventPort = ReceivePort(); + final ReceivePort _exitPort = ReceivePort(); + final ReceivePort _errorPort = ReceivePort(); + final StreamController _events = + StreamController.broadcast(); + final Map _isolates = {}; + final Map _commandPorts = {}; + + bool _disposed = false; + + /// Events emitted by the workers: results, logs, job completions, failures. + Stream get events => _events.stream; + + /// Worker ids in this pool. + Iterable get workerIds => List.generate(size, (index) => index); + + /// Spawns [size] isolates and waits until every one has built its client and + /// reported itself ready. + static Future spawn({ + required int size, + required HttpClientOption clientOption, + Duration timeout = const Duration(seconds: 30), + }) async { + assert(size > 0); + final pool = WorkerPool._(size, clientOption.id); + try { + await pool._start(clientOption.builder, timeout); + return pool; + } catch (_) { + await pool.dispose(); + rethrow; + } + } + + Future _start(HttpClientBuilder builder, Duration timeout) async { + final ready = Completer(); + + _eventPort.listen((message) { + if (message is! WorkerEvent) return; + if (message is WorkerReadyEvent) { + _commandPorts[message.workerId] = message.commandPort; + if (!ready.isCompleted && _commandPorts.length == size) { + ready.complete(); + } + return; + } + if (!_events.isClosed) _events.add(message); + }); + + _errorPort.listen((message) { + // Uncaught isolate errors arrive as [error, stackTrace]. + final description = message is List && message.isNotEmpty + ? message.first.toString() + : message.toString(); + if (!ready.isCompleted) { + ready.completeError(StateError('Worker isolate error: $description')); + } else if (!_events.isClosed) { + _events.add(WorkerFatalEvent(-1, 'Isolate error: $description')); + } + }); + + _exitPort.listen((_) { + if (_disposed || _events.isClosed) return; + _events.add(const WorkerFatalEvent(-1, 'A worker isolate exited.')); + }); + + for (var id = 0; id < size; id++) { + _isolates[id] = await Isolate.spawn( + benchmarkWorkerMain, + WorkerBootstrap( + workerId: id, + mainPort: _eventPort.sendPort, + clientBuilder: builder, + ), + debugName: 'benchmark-worker-$id', + onExit: _exitPort.sendPort, + onError: _errorPort.sendPort, + errorsAreFatal: false, + ); + } + + await ready.future.timeout( + timeout, + onTimeout: () => + throw TimeoutException('Workers did not start in time', timeout), + ); + } + + /// Sends [command] to a single worker. + void send(int workerId, WorkerCommand command) { + _commandPorts[workerId]?.send(command); + } + + /// Sends [command] to every worker. + void broadcast(WorkerCommand command) { + for (final port in _commandPorts.values) { + port.send(command); + } + } + + /// Shuts the workers down and releases the pool's ports. + Future dispose() async { + if (_disposed) return; + _disposed = true; + broadcast(const ShutdownCommand()); + // Give the isolates a moment to close their clients before killing them. + await Future.delayed(const Duration(milliseconds: 100)); + for (final isolate in _isolates.values) { + isolate.kill(priority: Isolate.immediate); + } + _isolates.clear(); + _commandPorts.clear(); + _eventPort.close(); + _exitPort.close(); + _errorPort.close(); + await _events.close(); + } +} diff --git a/benchmarker/lib/src/benchmark/worker_protocol.dart b/benchmarker/lib/src/benchmark/worker_protocol.dart new file mode 100644 index 0000000..2cf2913 --- /dev/null +++ b/benchmarker/lib/src/benchmark/worker_protocol.dart @@ -0,0 +1,173 @@ +import 'dart:isolate'; + +import 'http_client_builder.dart'; + +/// The spawn message handed to a worker isolate. +/// +/// Only sendable values are carried: [clientBuilder] must be a top-level or +/// static function. +class WorkerBootstrap { + const WorkerBootstrap({ + required this.workerId, + required this.mainPort, + required this.clientBuilder, + }); + + final int workerId; + final SendPort mainPort; + final HttpClientBuilder clientBuilder; +} + +/// Main isolate -> worker isolate. +sealed class WorkerCommand { + const WorkerCommand(); +} + +/// Issue [requestCount] sequential requests against [url]. +class RunJobCommand extends WorkerCommand { + const RunJobCommand({ + required this.jobId, + required this.url, + required this.requestCount, + required this.firstSequence, + }); + + final int jobId; + + /// Target URL as a string; [Uri] is parsed inside the worker. + final String url; + + final int requestCount; + + /// Global index of this job's first request, used to label results. + final int firstSequence; +} + +/// Stop the active job early. The worker still reports a [JobDoneEvent]. +class CancelJobCommand extends WorkerCommand { + const CancelJobCommand(); +} + +/// Close the worker's http client and let the isolate exit. +class ShutdownCommand extends WorkerCommand { + const ShutdownCommand(); +} + +/// Worker isolate -> main isolate. +sealed class WorkerEvent { + const WorkerEvent(this.workerId); + + final int workerId; +} + +/// Handshake: the worker is up, its client is built, and it is ready for jobs. +class WorkerReadyEvent extends WorkerEvent { + const WorkerReadyEvent(super.workerId, this.commandPort); + + final SendPort commandPort; +} + +/// A batch of completed request measurements. +class ResultBatchEvent extends WorkerEvent { + const ResultBatchEvent(super.workerId, this.results); + + final List results; +} + +/// A status or error line for the log view. +class WorkerLogEvent extends WorkerEvent { + const WorkerLogEvent(super.workerId, this.message, {this.isError = false}); + + final String message; + final bool isError; +} + +/// The worker finished (or cancelled) its job. +class JobDoneEvent extends WorkerEvent { + const JobDoneEvent(super.workerId, this.jobId, {required this.cancelled}); + + final int jobId; + final bool cancelled; +} + +/// The isolate died unexpectedly, or threw outside of a request. +class WorkerFatalEvent extends WorkerEvent { + const WorkerFatalEvent(super.workerId, this.message); + + final String message; +} + +/// How a single request ended. +enum RequestOutcome { + /// 2xx and the received byte count matched `Content-Length`. + success, + + /// 2xx but the received byte count did not match `Content-Length`. + lengthMismatch, + + /// 2xx but the response carried no `Content-Length` to verify against. + unverified, + + /// The response completed with a non-2xx status code. + httpError, + + /// The request threw before completing. + failure, +} + +/// The measurement for one request. +class RequestResult { + const RequestResult({ + required this.workerId, + required this.sequence, + required this.outcome, + required this.totalMicros, + required this.bytesReceived, + this.statusCode, + this.headerMicros, + this.firstByteMicros, + this.contentLength, + this.error, + }); + + final int workerId; + + /// Global request index, for log lines. + final int sequence; + + final RequestOutcome outcome; + + /// Time from request start until the response headers were available. + final int? headerMicros; + + /// Time from request start until the first response body byte arrived. + final int? firstByteMicros; + + /// Time from request start until the response body was fully read. + final int totalMicros; + + final int bytesReceived; + final int? contentLength; + final int? statusCode; + final String? error; + + bool get isSuccess => + outcome == RequestOutcome.success || outcome == RequestOutcome.unverified; + + /// A short description used for log lines and error grouping. + String describeProblem() { + switch (outcome) { + case RequestOutcome.success: + return 'ok'; + case RequestOutcome.unverified: + return 'no Content-Length to verify against'; + case RequestOutcome.lengthMismatch: + return 'byte mismatch: received $bytesReceived, ' + 'Content-Length $contentLength'; + case RequestOutcome.httpError: + return 'HTTP $statusCode'; + case RequestOutcome.failure: + return error ?? 'unknown error'; + } + } +} diff --git a/benchmarker/lib/src/ui/benchmark_page.dart b/benchmarker/lib/src/ui/benchmark_page.dart new file mode 100644 index 0000000..a69dded --- /dev/null +++ b/benchmarker/lib/src/ui/benchmark_page.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +import '../benchmark/benchmark_controller.dart'; +import 'widgets/cache_progress_panel.dart'; +import 'widgets/config_panel.dart'; +import 'widgets/log_panel.dart'; +import 'widgets/stats_panel.dart'; + +/// The single page of the benchmarker app. +class BenchmarkPage extends StatefulWidget { + const BenchmarkPage({super.key}); + + @override + State createState() => _BenchmarkPageState(); +} + +class _BenchmarkPageState extends State { + final BenchmarkController _controller = BenchmarkController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String get _status { + final controller = _controller; + switch (controller.phase) { + case BenchmarkPhase.idle: + return 'Idle'; + case BenchmarkPhase.preparing: + return 'Preparing…'; + case BenchmarkPhase.running: + return 'Running · ${controller.poolSize} workers'; + case BenchmarkPhase.cancelling: + return 'Cancelling…'; + case BenchmarkPhase.finished: + return 'Finished'; + case BenchmarkPhase.cancelled: + return 'Cancelled'; + case BenchmarkPhase.failed: + return 'Failed'; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('http_cache_stream benchmarker'), + actions: [ + ListenableBuilder( + listenable: _controller, + builder: (context, _) { + if (!_controller.phase.isBusy) return const SizedBox.shrink(); + return const Padding( + padding: EdgeInsets.only(right: 16), + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + }, + ), + ], + ), + body: ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final isBusy = _controller.phase.isBusy; + final config = ConfigPanel( + isBusy: isBusy, + canCancel: isBusy && _controller.phase != BenchmarkPhase.cancelling, + onRun: _controller.start, + onCancel: _controller.cancel, + ); + final progress = _controller.showsCacheProgress + ? CacheProgressPanel( + cacheState: _controller.cacheState, + cacheUrl: _controller.targetUrl, + ) + : null; + final stats = StatsPanel(stats: _controller.stats, status: _status); + final log = LogPanel( + logs: _controller.logs, + onClear: _controller.clearLogs, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 900; + if (!isWide) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + config, + if (progress != null) ...[ + const SizedBox(height: 16), + progress, + ], + const SizedBox(height: 16), + stats, + const SizedBox(height: 16), + log, + ], + ); + } + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 420, + child: ListView( + children: [ + config, + if (progress != null) ...[ + const SizedBox(height: 16), + progress, + ], + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: ListView( + children: [ + stats, + const SizedBox(height: 16), + log, + ], + ), + ), + ], + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/benchmarker/lib/src/ui/widgets/cache_progress_panel.dart b/benchmarker/lib/src/ui/widgets/cache_progress_panel.dart new file mode 100644 index 0000000..7343a4e --- /dev/null +++ b/benchmarker/lib/src/ui/widgets/cache_progress_panel.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:http_cache_stream/http_cache_stream.dart'; + +import '../../util/formatting.dart'; +import 'section_card.dart'; + +/// Download progress of the cache stream under test. +/// +/// Only shown for runs that go through the cache server; direct runs bypass +/// http_cache_stream entirely and have no cache state. +class CacheProgressPanel extends StatelessWidget { + const CacheProgressPanel({ + super.key, + required this.cacheState, + required this.cacheUrl, + }); + + final CacheState? cacheState; + final Uri? cacheUrl; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final state = cacheState; + final sourceLength = state?.sourceLength; + final position = state?.position ?? 0; + final progress = state?.progress; + + final String detail; + if (state == null) { + detail = 'Waiting for the cache stream…'; + } else if (sourceLength == null) { + detail = '${formatBytes(position)} cached · source length unknown'; + } else { + detail = '${formatBytes(position)} / ${formatBytes(sourceLength)} ' + '($position / $sourceLength bytes)'; + } + + return SectionCard( + title: 'Cache progress', + subtitle: cacheUrl?.toString(), + trailing: state?.isComplete == true + ? Chip( + avatar: const Icon(Icons.check, size: 16), + label: const Text('Complete'), + visualDensity: VisualDensity.compact, + side: BorderSide(color: theme.colorScheme.outlineVariant), + ) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text(detail, style: theme.textTheme.bodySmall), + ), + Text( + progress == null ? '—' : formatPercent(progress), + style: theme.textTheme.bodySmall, + ), + ], + ), + ], + ), + ); + } +} diff --git a/benchmarker/lib/src/ui/widgets/config_panel.dart b/benchmarker/lib/src/ui/widgets/config_panel.dart new file mode 100644 index 0000000..0e417d6 --- /dev/null +++ b/benchmarker/lib/src/ui/widgets/config_panel.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../benchmark/benchmark_config.dart'; +import '../../benchmark/http_client_builder.dart'; +import 'section_card.dart'; + +/// The benchmark inputs: source URL, concurrency, total requests, run type and +/// http client implementation. +class ConfigPanel extends StatefulWidget { + const ConfigPanel({ + super.key, + required this.isBusy, + required this.canCancel, + required this.onRun, + required this.onCancel, + }); + + final bool isBusy; + final bool canCancel; + final ValueChanged onRun; + final VoidCallback onCancel; + + @override + State createState() => _ConfigPanelState(); +} + +class _ConfigPanelState extends State { + static const String _defaultUrl = + 'https://download.samplelib.com/mp3/sample-15s.mp3'; + + final TextEditingController _urlController = + TextEditingController(text: _defaultUrl); + final TextEditingController _concurrencyController = + TextEditingController(text: '4'); + final TextEditingController _requestsController = + TextEditingController(text: '40'); + + BenchmarkType _type = BenchmarkType.preCached; + HttpClientOption _clientOption = kHttpClientOptions.first; + String? _error; + + @override + void dispose() { + _urlController.dispose(); + _concurrencyController.dispose(); + _requestsController.dispose(); + super.dispose(); + } + + void _run() { + final url = _urlController.text.trim(); + final concurrency = int.tryParse(_concurrencyController.text.trim()); + final totalRequests = int.tryParse(_requestsController.text.trim()); + + final error = BenchmarkConfig.validate( + url: url, + concurrency: concurrency, + totalRequests: totalRequests, + ); + setState(() => _error = error); + if (error != null) return; + + widget.onRun( + BenchmarkConfig( + sourceUrl: Uri.parse(url), + concurrency: concurrency!, + totalRequests: totalRequests!, + type: _type, + clientOption: _clientOption, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SectionCard( + title: 'Configuration', + subtitle: 'Requests are divided evenly between worker isolates.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _urlController, + enabled: !widget.isBusy, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Source URL', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _concurrencyController, + enabled: !widget.isBusy, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Concurrency (workers)', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _requestsController, + enabled: !widget.isBusy, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Total requests', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + segments: [ + for (final type in BenchmarkType.values) + ButtonSegment( + value: type, + label: Text(type.label), + ), + ], + selected: {_type}, + onSelectionChanged: widget.isBusy + ? null + : (selection) => setState(() => _type = selection.first), + ), + ), + ), + const SizedBox(height: 8), + Text( + _type.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + InputDecorator( + decoration: const InputDecoration( + labelText: 'HTTP client (per worker isolate)', + border: OutlineInputBorder(), + isDense: true, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _clientOption, + isExpanded: true, + isDense: true, + items: [ + for (final option in kHttpClientOptions) + DropdownMenuItem( + value: option, + child: + Text(option.label, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: widget.isBusy + ? null + : (option) => setState( + () => + _clientOption = option ?? kHttpClientOptions.first, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + _clientOption.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.error), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: widget.isBusy ? null : _run, + icon: const Icon(Icons.play_arrow), + label: const Text('Run benchmark'), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: widget.canCancel ? widget.onCancel : null, + icon: const Icon(Icons.stop), + label: const Text('Cancel'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/benchmarker/lib/src/ui/widgets/log_panel.dart b/benchmarker/lib/src/ui/widgets/log_panel.dart new file mode 100644 index 0000000..55500e0 --- /dev/null +++ b/benchmarker/lib/src/ui/widgets/log_panel.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../benchmark/benchmark_log.dart'; +import 'section_card.dart'; + +/// A read-only text field showing status lines and errors. +class LogPanel extends StatefulWidget { + const LogPanel({super.key, required this.logs, required this.onClear}); + + final List logs; + final VoidCallback onClear; + + @override + State createState() => _LogPanelState(); +} + +class _LogPanelState extends State { + final TextEditingController _textController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + int _renderedCount = -1; + + @override + void initState() { + super.initState(); + _syncText(); + } + + @override + void didUpdateWidget(covariant LogPanel oldWidget) { + super.didUpdateWidget(oldWidget); + _syncText(); + } + + void _syncText() { + if (_renderedCount == widget.logs.length) return; + _renderedCount = widget.logs.length; + _textController.text = widget.logs.map((entry) => '$entry').join('\n'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) return; + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + @override + void dispose() { + _textController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SectionCard( + title: 'Log', + subtitle: '${widget.logs.length} entries', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Copy log', + icon: const Icon(Icons.copy_all_outlined), + onPressed: widget.logs.isEmpty + ? null + : () { + Clipboard.setData( + ClipboardData(text: _textController.text), + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Log copied.')), + ); + }, + ), + IconButton( + tooltip: 'Clear log', + icon: const Icon(Icons.delete_outline), + onPressed: widget.logs.isEmpty ? null : widget.onClear, + ), + ], + ), + child: SizedBox( + height: 260, + child: TextField( + controller: _textController, + scrollController: _scrollController, + readOnly: true, + expands: true, + maxLines: null, + minLines: null, + textAlignVertical: TextAlignVertical.top, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + height: 1.4, + ), + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + contentPadding: EdgeInsets.all(12), + hintText: 'Status and error output appears here.', + ), + ), + ), + ); + } +} diff --git a/benchmarker/lib/src/ui/widgets/section_card.dart b/benchmarker/lib/src/ui/widgets/section_card.dart new file mode 100644 index 0000000..a9c7543 --- /dev/null +++ b/benchmarker/lib/src/ui/widgets/section_card.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +/// A titled card used for each panel of the benchmark page. +class SectionCard extends StatelessWidget { + const SectionCard({ + super.key, + required this.title, + required this.child, + this.subtitle, + this.trailing, + }); + + final String title; + final String? subtitle; + final Widget? trailing; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: theme.textTheme.titleMedium), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} diff --git a/benchmarker/lib/src/ui/widgets/stats_panel.dart b/benchmarker/lib/src/ui/widgets/stats_panel.dart new file mode 100644 index 0000000..3c60df4 --- /dev/null +++ b/benchmarker/lib/src/ui/widgets/stats_panel.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; + +import '../../benchmark/benchmark_stats.dart'; +import '../../util/formatting.dart'; +import 'section_card.dart'; + +/// Aggregated results of the current or most recent run. +class StatsPanel extends StatelessWidget { + const StatsPanel({super.key, required this.stats, required this.status}); + + final BenchmarkStats? stats; + + /// Short status line shown next to the title, e.g. `Running`. + final String status; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final stats = this.stats; + + return SectionCard( + title: 'Statistics', + subtitle: status, + child: stats == null + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + 'Run a benchmark to see results.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: stats.progress, + minHeight: 6, + ), + ), + const SizedBox(height: 16), + _TileGrid(stats: stats), + const SizedBox(height: 20), + _TimingTable(stats: stats), + const SizedBox(height: 16), + _OutcomeChips(stats: stats), + ], + ), + ); + } +} + +class _TileGrid extends StatelessWidget { + const _TileGrid({required this.stats}); + + final BenchmarkStats stats; + + @override + Widget build(BuildContext context) { + final tiles = <_Tile>[ + _Tile('Requests', '${stats.completed} / ${stats.totalRequests}'), + _Tile('Throughput', formatRate(stats.requestsPerSecond, 'req/s')), + _Tile('Bandwidth', formatBytesPerSecond(stats.bytesPerSecond)), + _Tile('Elapsed', formatDuration(stats.elapsed)), + _Tile( + 'Avg completion', + stats.completionTime == null + ? '—' + : formatDuration(stats.completionTime!.avg), + ), + _Tile( + 'Avg headers', + stats.headerTime == null ? '—' : formatDuration(stats.headerTime!.avg), + ), + _Tile( + 'Avg first byte', + stats.firstByteTime == null + ? '—' + : formatDuration(stats.firstByteTime!.avg), + ), + _Tile('Bytes received', formatBytes(stats.totalBytes)), + _Tile('Avg response size', formatBytes(stats.avgBytesPerRequest)), + _Tile( + 'Problems', + '${stats.errorCount}', + isError: stats.errorCount > 0, + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + const spacing = 12.0; + final columns = constraints.maxWidth ~/ 170; + final columnCount = columns.clamp(2, 5); + final tileWidth = + (constraints.maxWidth - spacing * (columnCount - 1)) / columnCount; + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final tile in tiles) + SizedBox(width: tileWidth, child: tile), + ], + ); + }, + ); + } +} + +class _Tile extends StatelessWidget { + const _Tile(this.label, this.value, {this.isError = false}); + + final String label; + final String value; + final bool isError; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + style: theme.textTheme.titleMedium?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + color: isError ? theme.colorScheme.error : null, + ), + ), + ), + ], + ), + ); + } +} + +class _TimingTable extends StatelessWidget { + const _TimingTable({required this.stats}); + + final BenchmarkStats stats; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final rows = <({String label, TimingStats? timing})>[ + (label: 'Response headers', timing: stats.headerTime), + (label: 'First byte', timing: stats.firstByteTime), + (label: 'Completion', timing: stats.completionTime), + ]; + + Widget cell(String text, {bool header = false, bool leading = false}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Text( + text, + textAlign: leading ? TextAlign.left : TextAlign.right, + style: (header + ? theme.textTheme.labelSmall + : theme.textTheme.bodySmall) + ?.copyWith( + color: header ? theme.colorScheme.onSurfaceVariant : null, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 520), + child: Table( + columnWidths: const {0: IntrinsicColumnWidth()}, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + border: TableBorder( + horizontalInside: BorderSide( + color: theme.colorScheme.outlineVariant, + width: 0.5, + ), + ), + children: [ + TableRow( + children: [ + cell('Timing', header: true, leading: true), + cell('avg', header: true), + cell('p50', header: true), + cell('p90', header: true), + cell('p99', header: true), + cell('min', header: true), + cell('max', header: true), + ], + ), + for (final row in rows) + TableRow( + children: [ + cell(row.label, leading: true), + cell(_format(row.timing?.avg)), + cell(_format(row.timing?.p50)), + cell(_format(row.timing?.p90)), + cell(_format(row.timing?.p99)), + cell(_format(row.timing?.min)), + cell(_format(row.timing?.max)), + ], + ), + ], + ), + ), + ); + } + + static String _format(Duration? duration) => + duration == null ? '—' : formatDuration(duration); +} + +class _OutcomeChips extends StatelessWidget { + const _OutcomeChips({required this.stats}); + + final BenchmarkStats stats; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final entries = <({String label, int count, bool bad})>[ + (label: 'verified', count: stats.succeeded, bad: false), + (label: 'unverified', count: stats.unverified, bad: false), + (label: 'byte mismatch', count: stats.lengthMismatches, bad: true), + (label: 'HTTP errors', count: stats.httpErrors, bad: true), + (label: 'failures', count: stats.failures, bad: true), + ]; + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in entries) + Chip( + visualDensity: VisualDensity.compact, + label: Text('${entry.label}: ${entry.count}'), + side: BorderSide( + color: entry.bad && entry.count > 0 + ? theme.colorScheme.error + : theme.colorScheme.outlineVariant, + ), + ), + ], + ); + } +} diff --git a/benchmarker/lib/src/util/formatting.dart b/benchmarker/lib/src/util/formatting.dart new file mode 100644 index 0000000..61ee429 --- /dev/null +++ b/benchmarker/lib/src/util/formatting.dart @@ -0,0 +1,52 @@ +/// Formatting helpers shared by the log and the statistics views. +library; + +const List _byteUnits = ['B', 'KB', 'MB', 'GB', 'TB']; + +/// Formats a byte count using binary units, e.g. `1.44 MB`. +String formatBytes(num bytes, {int fractionDigits = 2}) { + if (bytes.isNaN || bytes.isInfinite) return '—'; + var value = bytes.toDouble(); + var unit = 0; + while (value.abs() >= 1024 && unit < _byteUnits.length - 1) { + value /= 1024; + unit++; + } + final digits = unit == 0 ? 0 : fractionDigits; + return '${value.toStringAsFixed(digits)} ${_byteUnits[unit]}'; +} + +/// Formats a throughput in bytes per second, e.g. `12.30 MB/s`. +String formatBytesPerSecond(num bytesPerSecond) => + '${formatBytes(bytesPerSecond)}/s'; + +/// Formats a duration with a resolution that suits its magnitude. +String formatDuration(Duration duration) { + final micros = duration.inMicroseconds; + if (micros < 1000) return '$micros µs'; + if (micros < Duration.microsecondsPerSecond) { + return '${(micros / 1000).toStringAsFixed(2)} ms'; + } + if (micros < Duration.microsecondsPerMinute) { + return '${(micros / Duration.microsecondsPerSecond).toStringAsFixed(2)} s'; + } + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '${minutes}m ${seconds}s'; +} + +/// Formats a rate with two decimals, e.g. `123.45 req/s`. +String formatRate(double value, String unit) => + '${value.toStringAsFixed(2)} $unit'; + +/// Formats a 0-1 fraction as a percentage, e.g. `42.1%`. +String formatPercent(double fraction, {int fractionDigits = 1}) => + '${(fraction * 100).toStringAsFixed(fractionDigits)}%'; + +/// Formats a wall-clock time as `HH:mm:ss.SSS`. +String formatClockTime(DateTime time) { + String pad(int value, [int width = 2]) => + value.toString().padLeft(width, '0'); + return '${pad(time.hour)}:${pad(time.minute)}:${pad(time.second)}' + '.${pad(time.millisecond, 3)}'; +} diff --git a/benchmarker/linux/.gitignore b/benchmarker/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/benchmarker/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/benchmarker/linux/CMakeLists.txt b/benchmarker/linux/CMakeLists.txt new file mode 100644 index 0000000..c2b4e3a --- /dev/null +++ b/benchmarker/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "benchmarker") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.httpcachestream.benchmarker") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/benchmarker/linux/flutter/CMakeLists.txt b/benchmarker/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/benchmarker/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/benchmarker/linux/flutter/generated_plugin_registrant.cc b/benchmarker/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/benchmarker/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/benchmarker/linux/flutter/generated_plugin_registrant.h b/benchmarker/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/benchmarker/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/benchmarker/linux/flutter/generated_plugins.cmake b/benchmarker/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..be1ee3e --- /dev/null +++ b/benchmarker/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/benchmarker/linux/runner/CMakeLists.txt b/benchmarker/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/benchmarker/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/benchmarker/linux/runner/main.cc b/benchmarker/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/benchmarker/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/benchmarker/linux/runner/my_application.cc b/benchmarker/linux/runner/my_application.cc new file mode 100644 index 0000000..e4343f3 --- /dev/null +++ b/benchmarker/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "benchmarker"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "benchmarker"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/benchmarker/linux/runner/my_application.h b/benchmarker/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/benchmarker/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/benchmarker/macos/.gitignore b/benchmarker/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/benchmarker/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/benchmarker/macos/Flutter/Flutter-Debug.xcconfig b/benchmarker/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/benchmarker/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/benchmarker/macos/Flutter/Flutter-Release.xcconfig b/benchmarker/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/benchmarker/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/benchmarker/macos/Flutter/GeneratedPluginRegistrant.swift b/benchmarker/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..cccf817 --- /dev/null +++ b/benchmarker/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/benchmarker/macos/Runner.xcodeproj/project.pbxproj b/benchmarker/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ad2bd89 --- /dev/null +++ b/benchmarker/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* benchmarker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "benchmarker.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* benchmarker.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* benchmarker.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/benchmarker.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/benchmarker"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/benchmarker.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/benchmarker"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/benchmarker.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/benchmarker"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/benchmarker/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/benchmarker/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/benchmarker/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/benchmarker/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/benchmarker/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..7891326 --- /dev/null +++ b/benchmarker/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarker/macos/Runner.xcworkspace/contents.xcworkspacedata b/benchmarker/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/benchmarker/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/benchmarker/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/benchmarker/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/benchmarker/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/benchmarker/macos/Runner/AppDelegate.swift b/benchmarker/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/benchmarker/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..82b6f9d9a33e198f5747104729e1fcef999772a5 GIT binary patch literal 102994 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ⅅ8MglG&ER{i5lN315cO?EdHNLrg? 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 z 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 Pe7HEdAEg|=c=LY&YVNkY literal 0 HcmV?d00001 diff --git a/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..13b35eba55c6dabc3aac36f33d859266c18fa0d0 GIT binary patch literal 5680 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 literal 0 HcmV?d00001 diff --git a/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3f5fa40fb3d1e0710331a48de5d256da3f275d GIT binary patch literal 520 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 literal 0 HcmV?d00001 diff --git a/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1632cfddf3d9dade342351e627a0a75609fb46 GIT binary patch literal 2218 zcmV;b2vzrqP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE6iGxuRCodHTWf3-RTMruyW6Fu zQYeUM04eX6D5c0FCjKKPrco1(K`<0SL=crI{PC3-^hZU0kQie$gh-5!7z6SH6Q0J% zqot*`H1q{R5fHFYS}dje@;kG=v$L0(yY0?wY2%*c?A&{2?!D*x?m71{of2gv!$5|C z3>qG_BW}7K_yUcT3A5C6QD<+{aq?x;MAUyAiJn#Jv8_zZtQ{P zTRzbL3U9!qVuZzS$xKU10KiW~Bgdcv1-!uAhQxf3a7q+dU6lj?yoO4Lq4TUN4}h{N z*fIM=SS8|C2$(T>w$`t@3Tka!(r!7W`x z-isCVgQD^mG-MJ;XtJuK3V{Vy72GQ83KRWsHU?e*wrhKk=ApIYeDqLi;JI1e zuvv}5^Dc=k7F7?nm3nIw$NVmU-+R>> zyqOR$-2SDpJ}Pt;^RkJytDVXNTsu|mI1`~G7yw`EJR?VkGfNdqK9^^8P`JdtTV&tX4CNcV4 z&N06nZa??Fw1AgQOUSE2AmPE@WO(Fvo`%m`cDgiv(fAeRA%3AGXUbsGw{7Q`cY;1BI#ac3iN$$Hw z0LT0;xc%=q)me?Y*$xI@GRAw?+}>=9D+KTk??-HJ4=A>`V&vKFS75@MKdSF1JTq{S zc1!^8?YA|t+uKigaq!sT;Z!&0F2=k7F0PIU;F$leJLaw2UI6FL^w}OG&!;+b%ya1c z1n+6-inU<0VM-Y_s5iTElq)ThyF?StVcebpGI znw#+zLx2@ah{$_2jn+@}(zJZ{+}_N9BM;z)0yr|gF-4=Iyu@hI*Lk=-A8f#bAzc9f z`Kd6K--x@t04swJVC3JK1cHY-Hq+=|PN-VO;?^_C#;coU6TDP7Bt`;{JTG;!+jj(` zw5cLQ-(Cz-Tlb`A^w7|R56Ce;Wmr0)$KWOUZ6ai0PhzPeHwdl0H(etP zUV`va_i0s-4#DkNM8lUlqI7>YQLf)(lz9Q3Uw`)nc(z3{m5ZE77Ul$V%m)E}3&8L0 z-XaU|eB~Is08eORPk;=<>!1w)Kf}FOVS2l&9~A+@R#koFJ$Czd%Y(ENTV&A~U(IPI z;UY+gf+&6ioZ=roly<0Yst8ck>(M=S?B-ys3mLdM&)ex!hbt+ol|T6CTS+Sc0jv(& z7ijdvFwBq;0a{%3GGwkDKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U z@~$z0V`OVtIbEx5pa|Tct|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-M2 z@O~lQ0OiKQp}o9I;?uxCgYVV?FH|?Riri*U$Zi_`V2eiA>l zdSm6;SEm6#T+SpcE8Ro_f2AwxzI z44hfe^WE3!h@W3RDyA_H440cpmYkv*)6m1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarker/macos/Runner/Configs/AppInfo.xcconfig b/benchmarker/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..280ef59 --- /dev/null +++ b/benchmarker/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = benchmarker + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.httpcachestream.benchmarker + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.httpcachestream. All rights reserved. diff --git a/benchmarker/macos/Runner/Configs/Debug.xcconfig b/benchmarker/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/benchmarker/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/benchmarker/macos/Runner/Configs/Release.xcconfig b/benchmarker/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/benchmarker/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/benchmarker/macos/Runner/Configs/Warnings.xcconfig b/benchmarker/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/benchmarker/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/benchmarker/macos/Runner/DebugProfile.entitlements b/benchmarker/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..08c3ab1 --- /dev/null +++ b/benchmarker/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/benchmarker/macos/Runner/Info.plist b/benchmarker/macos/Runner/Info.plist new file mode 100644 index 0000000..c527d81 --- /dev/null +++ b/benchmarker/macos/Runner/Info.plist @@ -0,0 +1,37 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/benchmarker/macos/Runner/MainFlutterWindow.swift b/benchmarker/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/benchmarker/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/benchmarker/macos/Runner/Release.entitlements b/benchmarker/macos/Runner/Release.entitlements new file mode 100644 index 0000000..64cabb4 --- /dev/null +++ b/benchmarker/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/benchmarker/macos/RunnerTests/RunnerTests.swift b/benchmarker/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/benchmarker/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/benchmarker/pubspec.lock b/benchmarker/pubspec.lock new file mode 100644 index 0000000..99f3845 --- /dev/null +++ b/benchmarker/pubspec.lock @@ -0,0 +1,452 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_cache_stream: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.1.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + url: "https://pub.dev" + source: hosted + version: "3.4.1+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/benchmarker/pubspec.yaml b/benchmarker/pubspec.yaml new file mode 100644 index 0000000..e1ab17d --- /dev/null +++ b/benchmarker/pubspec.yaml @@ -0,0 +1,27 @@ +name: benchmarker +description: "Benchmarking harness for http_cache_stream" +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + http: ^1.3.0 + + http_cache_stream: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/benchmarker/test/benchmark_config_test.dart b/benchmarker/test/benchmark_config_test.dart new file mode 100644 index 0000000..cc4df25 --- /dev/null +++ b/benchmarker/test/benchmark_config_test.dart @@ -0,0 +1,98 @@ +import 'package:benchmarker/src/benchmark/benchmark_config.dart'; +import 'package:benchmarker/src/benchmark/http_client_builder.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BenchmarkConfig _config({required int concurrency, required int total}) { + return BenchmarkConfig( + sourceUrl: Uri.parse('https://example.com/file.mp3'), + concurrency: concurrency, + totalRequests: total, + type: BenchmarkType.direct, + clientOption: kHttpClientOptions.first, + ); +} + +void main() { + group('requestDistribution', () { + test('divides evenly when it can', () { + expect( + _config(concurrency: 4, total: 40).requestDistribution(), + [10, 10, 10, 10], + ); + }); + + test('hands the remainder to the lowest-numbered workers', () { + expect( + _config(concurrency: 4, total: 10).requestDistribution(), + [3, 3, 2, 2], + ); + }); + + test('always sums to the total request count', () { + for (var concurrency = 1; concurrency <= 16; concurrency++) { + for (var total = concurrency; total <= 200; total += 7) { + final distribution = + _config(concurrency: concurrency, total: total).requestDistribution(); + expect(distribution, hasLength(concurrency)); + expect(distribution.reduce((a, b) => a + b), total); + expect(distribution.every((count) => count > 0), isTrue); + } + } + }); + }); + + group('validate', () { + test('accepts a well-formed config', () { + expect( + BenchmarkConfig.validate( + url: 'https://example.com/file.mp3', + concurrency: 4, + totalRequests: 40, + ), + isNull, + ); + }); + + test('rejects a malformed or non-http url', () { + expect( + BenchmarkConfig.validate(url: '', concurrency: 1, totalRequests: 1), + isNotNull, + ); + expect( + BenchmarkConfig.validate( + url: 'not a url', + concurrency: 1, + totalRequests: 1, + ), + isNotNull, + ); + expect( + BenchmarkConfig.validate( + url: 'ftp://example.com/file.mp3', + concurrency: 1, + totalRequests: 1, + ), + isNotNull, + ); + }); + + test('rejects out-of-range counts', () { + expect( + BenchmarkConfig.validate( + url: 'https://example.com/f', + concurrency: 0, + totalRequests: 10, + ), + isNotNull, + ); + expect( + BenchmarkConfig.validate( + url: 'https://example.com/f', + concurrency: 4, + totalRequests: 2, + ), + isNotNull, + ); + }); + }); +} diff --git a/benchmarker/test/benchmark_controller_test.dart b/benchmarker/test/benchmark_controller_test.dart new file mode 100644 index 0000000..d24b896 --- /dev/null +++ b/benchmarker/test/benchmark_controller_test.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:benchmarker/src/benchmark/benchmark_config.dart'; +import 'package:benchmarker/src/benchmark/benchmark_controller.dart'; +import 'package:benchmarker/src/benchmark/http_client_builder.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_cache_stream/http_cache_stream.dart'; + +/// End-to-end coverage of a benchmark run: a real origin server, a real +/// [HttpCacheManager] with its local cache server, and real worker isolates. +void main() { + final payload = List.generate(256 * 1024, (index) => index % 256); + late HttpServer origin; + late Directory cacheDir; + late BenchmarkController controller; + late Uri sourceUrl; + var originRequests = 0; + + setUp(() async { + originRequests = 0; + origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + unawaited(() async { + await for (final request in origin) { + originRequests++; + request.response.headers.contentLength = payload.length; + request.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + request.response.add(payload); + await request.response.close(); + } + }()); + // Addressed as `localhost` rather than the bound `127.0.0.1` so the source + // host differs from the cache server's host; otherwise http_cache_stream + // treats the source URL as an already-encoded cache URL. + sourceUrl = Uri.parse('http://localhost:${origin.port}/payload.bin'); + + cacheDir = await Directory.systemTemp.createTemp('benchmarker_test'); + await HttpCacheManager.init( + config: GlobalCacheConfig(cacheDirectory: cacheDir), + ); + controller = BenchmarkController(); + }); + + tearDown(() async { + controller.dispose(); + await HttpCacheManager.instanceOrNull?.dispose(); + await origin.close(force: true); + if (cacheDir.existsSync()) { + await cacheDir.delete(recursive: true); + } + }); + + BenchmarkConfig configFor(BenchmarkType type, {int workers = 2, int total = 4}) { + return BenchmarkConfig( + sourceUrl: sourceUrl, + concurrency: workers, + totalRequests: total, + type: type, + clientOption: kHttpClientOptions.first, + ); + } + + test('direct run bypasses the cache server', () async { + await controller.start(configFor(BenchmarkType.direct)); + + expect(controller.phase, BenchmarkPhase.finished); + expect(controller.targetUrl, sourceUrl); + expect(controller.showsCacheProgress, isFalse); + expect(controller.cacheState, isNull); + + final stats = controller.stats!; + expect(stats.completed, 4); + expect(stats.succeeded, 4); + expect(stats.errorCount, 0); + expect(stats.totalBytes, 4 * payload.length); + expect(stats.headerTime!.count, 4); + expect(stats.firstByteTime!.count, 4); + expect(stats.completionTime!.count, 4); + expect(originRequests, 4); + expect(cacheDir.listSync(), isEmpty); + }); + + test('pre-cached run serves every request from the completed cache', + () async { + await controller.start(configFor(BenchmarkType.preCached)); + + expect(controller.phase, BenchmarkPhase.finished); + expect(controller.targetUrl, isNot(sourceUrl)); + expect(controller.showsCacheProgress, isTrue); + expect(controller.cacheState!.isComplete, isTrue); + expect(controller.cacheState!.sourceLength, payload.length); + + final stats = controller.stats!; + expect(stats.completed, 4); + expect(stats.succeeded, 4); + expect(stats.errorCount, 0); + expect(stats.totalBytes, 4 * payload.length); + // One download to pre-cache; the benchmarked requests are served from disk. + expect(originRequests, 1); + }); + + test('non-cached run wipes the cache and downloads while serving', () async { + await controller.start(configFor(BenchmarkType.preCached, total: 2)); + expect(controller.stats!.errorCount, 0); + final requestsAfterPreCache = originRequests; + + await controller.start(configFor(BenchmarkType.nonCached, total: 2)); + + expect(controller.phase, BenchmarkPhase.finished); + final stats = controller.stats!; + expect(stats.completed, 2); + expect(stats.succeeded, 2); + expect(stats.errorCount, 0); + expect(stats.totalBytes, 2 * payload.length); + // The cache was wiped, so the source had to be fetched again. + expect(originRequests, greaterThan(requestsAfterPreCache)); + expect( + controller.logs.map((entry) => entry.message), + contains('Cache wiped.'), + ); + }); + + test('the worker pool is reused between runs with the same settings', + () async { + await controller.start(configFor(BenchmarkType.direct, total: 2)); + await controller.start(configFor(BenchmarkType.direct, total: 2)); + + expect(controller.phase, BenchmarkPhase.finished); + expect(controller.poolSize, 2); + expect( + controller.logs.map((entry) => entry.message), + contains('Reusing 2 warm worker isolates.'), + ); + }); +} diff --git a/benchmarker/test/benchmark_stats_test.dart b/benchmarker/test/benchmark_stats_test.dart new file mode 100644 index 0000000..d64abea --- /dev/null +++ b/benchmarker/test/benchmark_stats_test.dart @@ -0,0 +1,99 @@ +import 'package:benchmarker/src/benchmark/benchmark_stats.dart'; +import 'package:benchmarker/src/benchmark/worker_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +RequestResult _result({ + required RequestOutcome outcome, + int totalMicros = 1000, + int? headerMicros = 100, + int? firstByteMicros = 200, + int bytes = 1024, + int? contentLength = 1024, +}) { + return RequestResult( + workerId: 0, + sequence: 0, + outcome: outcome, + totalMicros: totalMicros, + bytesReceived: bytes, + contentLength: contentLength, + headerMicros: headerMicros, + firstByteMicros: firstByteMicros, + statusCode: 200, + ); +} + +void main() { + group('TimingStats', () { + test('returns null without samples', () { + expect(TimingStats.fromSamples([]), isNull); + }); + + test('computes average, extremes and percentiles', () { + final stats = TimingStats.fromSamples( + List.generate(100, (index) => (index + 1) * 10), + )!; + expect(stats.count, 100); + expect(stats.minMicros, 10); + expect(stats.maxMicros, 1000); + expect(stats.avgMicros, closeTo(505, 0.001)); + // Percentiles use the nearest rank of the sorted samples. + expect(stats.p50Micros, 510); + expect(stats.p90Micros, 900); + expect(stats.p99Micros, 990); + }); + }); + + group('StatsAccumulator', () { + test('counts outcomes and bytes', () { + final accumulator = StatsAccumulator(4) + ..add(_result(outcome: RequestOutcome.success)) + ..add(_result(outcome: RequestOutcome.unverified, contentLength: null)) + ..add(_result(outcome: RequestOutcome.lengthMismatch, bytes: 512)) + ..add( + _result( + outcome: RequestOutcome.failure, + bytes: 0, + headerMicros: null, + firstByteMicros: null, + ), + ); + + final stats = accumulator.snapshot(const Duration(seconds: 2)); + expect(stats.completed, 4); + expect(stats.succeeded, 1); + expect(stats.unverified, 1); + expect(stats.lengthMismatches, 1); + expect(stats.failures, 1); + expect(stats.errorCount, 2); + expect(stats.totalBytes, 1024 + 1024 + 512); + expect(stats.requestsPerSecond, closeTo(2, 0.0001)); + expect(stats.bytesPerSecond, closeTo(1280, 0.0001)); + }); + + test('excludes failed requests from the timing series', () { + final accumulator = StatsAccumulator(2) + ..add(_result(outcome: RequestOutcome.success, totalMicros: 500)) + ..add( + _result( + outcome: RequestOutcome.failure, + totalMicros: 9999, + headerMicros: null, + firstByteMicros: null, + ), + ); + + final stats = accumulator.snapshot(const Duration(seconds: 1)); + expect(stats.completionTime!.count, 1); + expect(stats.completionTime!.maxMicros, 500); + expect(stats.headerTime!.count, 1); + expect(stats.firstByteTime!.count, 1); + }); + + test('reports progress against the configured total', () { + final accumulator = StatsAccumulator(10) + ..add(_result(outcome: RequestOutcome.success)); + expect(accumulator.snapshot(Duration.zero).progress, closeTo(0.1, 1e-9)); + }); + }); +} diff --git a/benchmarker/test/worker_pool_test.dart b/benchmarker/test/worker_pool_test.dart new file mode 100644 index 0000000..e91d8e8 --- /dev/null +++ b/benchmarker/test/worker_pool_test.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:benchmarker/src/benchmark/http_client_builder.dart'; +import 'package:benchmarker/src/benchmark/worker_pool.dart'; +import 'package:benchmarker/src/benchmark/worker_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Serves a fixed payload with a correct `Content-Length`, plus endpoints for a +/// chunked (length-less) response and an error status. +Future _startOrigin(List payload) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + unawaited(() async { + await for (final request in server) { + switch (request.uri.path) { + case '/chunked': + request.response.headers.chunkedTransferEncoding = true; + request.response.add(payload); + case '/error': + request.response.statusCode = HttpStatus.notFound; + default: + request.response.headers.contentLength = payload.length; + request.response.add(payload); + } + await request.response.close(); + } + }()); + return server; +} + +void main() { + final payload = List.generate(64 * 1024, (index) => index % 256); + late HttpServer origin; + late WorkerPool pool; + + setUp(() async { + origin = await _startOrigin(payload); + pool = await WorkerPool.spawn( + size: 2, + clientOption: kHttpClientOptions.first, + ); + }); + + tearDown(() async { + await pool.dispose(); + await origin.close(force: true); + }); + + /// Dispatches [perWorker] requests to each worker and collects every result. + Future> run(String path, List perWorker) async { + final results = []; + final done = {}; + final completer = Completer(); + final outstanding = { + for (var id = 0; id < perWorker.length; id++) + if (perWorker[id] > 0) id, + }; + + final subscription = pool.events.listen((event) { + if (event is ResultBatchEvent) { + results.addAll(event.results); + } else if (event is JobDoneEvent) { + done.add(event.workerId); + if (done.containsAll(outstanding) && !completer.isCompleted) { + completer.complete(); + } + } + }); + + var sequence = 0; + for (var id = 0; id < perWorker.length; id++) { + if (perWorker[id] == 0) continue; + pool.send( + id, + RunJobCommand( + jobId: 1, + url: 'http://${origin.address.host}:${origin.port}$path', + requestCount: perWorker[id], + firstSequence: sequence, + ), + ); + sequence += perWorker[id]; + } + + await completer.future.timeout(const Duration(seconds: 30)); + await subscription.cancel(); + return results; + } + + test('workers report one verified result per request', () async { + final results = await run('/payload.bin', [3, 2]); + + expect(results, hasLength(5)); + expect( + results.every((result) => result.outcome == RequestOutcome.success), + isTrue, + reason: results.map((result) => result.describeProblem()).join(', '), + ); + expect( + results.every((result) => result.bytesReceived == payload.length), + isTrue, + ); + expect(results.map((result) => result.workerId).toSet(), {0, 1}); + expect(results.map((result) => result.sequence).toSet(), {0, 1, 2, 3, 4}); + for (final result in results) { + expect(result.headerMicros, isNotNull); + expect(result.firstByteMicros, isNotNull); + expect(result.totalMicros, greaterThanOrEqualTo(result.headerMicros!)); + expect(result.contentLength, payload.length); + } + }); + + test('a response without Content-Length is reported as unverified', () async { + final results = await run('/chunked', [1, 0]); + + expect(results, hasLength(1)); + expect(results.single.outcome, RequestOutcome.unverified); + expect(results.single.bytesReceived, payload.length); + expect(results.single.contentLength, isNull); + }); + + test('a non-2xx response is reported as an http error', () async { + final results = await run('/error', [1, 0]); + + expect(results, hasLength(1)); + expect(results.single.outcome, RequestOutcome.httpError); + expect(results.single.statusCode, HttpStatus.notFound); + }); + + test('the pool reuses its isolates across jobs', () async { + final first = await run('/payload.bin', [1, 1]); + final second = await run('/payload.bin', [1, 1]); + + expect(first, hasLength(2)); + expect(second, hasLength(2)); + expect(pool.size, 2); + }); +} diff --git a/benchmarker/windows/.gitignore b/benchmarker/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/benchmarker/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/benchmarker/windows/CMakeLists.txt b/benchmarker/windows/CMakeLists.txt new file mode 100644 index 0000000..456a1ff --- /dev/null +++ b/benchmarker/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(benchmarker LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "benchmarker") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/benchmarker/windows/flutter/CMakeLists.txt b/benchmarker/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/benchmarker/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/benchmarker/windows/flutter/generated_plugin_registrant.cc b/benchmarker/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/benchmarker/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/benchmarker/windows/flutter/generated_plugin_registrant.h b/benchmarker/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/benchmarker/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/benchmarker/windows/flutter/generated_plugins.cmake b/benchmarker/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..3ad69c6 --- /dev/null +++ b/benchmarker/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/benchmarker/windows/runner/CMakeLists.txt b/benchmarker/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/benchmarker/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/benchmarker/windows/runner/Runner.rc b/benchmarker/windows/runner/Runner.rc new file mode 100644 index 0000000..ad12389 --- /dev/null +++ b/benchmarker/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.httpcachestream" "\0" + VALUE "FileDescription", "benchmarker" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "benchmarker" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.httpcachestream. All rights reserved." "\0" + VALUE "OriginalFilename", "benchmarker.exe" "\0" + VALUE "ProductName", "benchmarker" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/benchmarker/windows/runner/flutter_window.cpp b/benchmarker/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/benchmarker/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/benchmarker/windows/runner/flutter_window.h b/benchmarker/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/benchmarker/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/benchmarker/windows/runner/main.cpp b/benchmarker/windows/runner/main.cpp new file mode 100644 index 0000000..3055537 --- /dev/null +++ b/benchmarker/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"benchmarker", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/benchmarker/windows/runner/resource.h b/benchmarker/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/benchmarker/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/benchmarker/windows/runner/resources/app_icon.ico b/benchmarker/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/benchmarker/windows/runner/runner.exe.manifest b/benchmarker/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/benchmarker/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/benchmarker/windows/runner/utils.cpp b/benchmarker/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/benchmarker/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/benchmarker/windows/runner/utils.h b/benchmarker/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/benchmarker/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/benchmarker/windows/runner/win32_window.cpp b/benchmarker/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/benchmarker/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/benchmarker/windows/runner/win32_window.h b/benchmarker/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/benchmarker/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ From ffccdb514aa840422555b0e5acd20a9e9707ce81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:06:37 +0000 Subject: [PATCH 2/5] Keep benchmark configuration alive when scrolled out of view The config inputs lived in ConfigPanel's State, which the lazily-built lists dispose once the panel leaves the viewport's cache extent. Scrolling to the bottom of the page and back destroyed the TextEditingControllers and the selected run type and client, resetting them to their defaults. Switching between the narrow and wide layouts had the same effect. Move that state into a BenchmarkForm owned by the page, and make ConfigPanel a stateless view over it. Adds a widget test that asserts the panel is really disposed while scrolled away and that every input survives the round trip. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JvD7reUpdgiJeHaJz2XSE --- benchmarker/lib/src/ui/benchmark_form.dart | 91 ++++++ benchmarker/lib/src/ui/benchmark_page.dart | 7 + .../lib/src/ui/widgets/config_panel.dart | 301 ++++++++---------- benchmarker/test/benchmark_page_test.dart | 73 +++++ 4 files changed, 302 insertions(+), 170 deletions(-) create mode 100644 benchmarker/lib/src/ui/benchmark_form.dart create mode 100644 benchmarker/test/benchmark_page_test.dart diff --git a/benchmarker/lib/src/ui/benchmark_form.dart b/benchmarker/lib/src/ui/benchmark_form.dart new file mode 100644 index 0000000..9a098cf --- /dev/null +++ b/benchmarker/lib/src/ui/benchmark_form.dart @@ -0,0 +1,91 @@ +import 'package:flutter/widgets.dart'; + +import '../benchmark/benchmark_config.dart'; +import '../benchmark/http_client_builder.dart'; + +/// Holds the benchmark inputs. +/// +/// The form state lives on the page rather than inside [ConfigPanel] so it +/// survives the panel being disposed and rebuilt — which happens whenever the +/// panel scrolls out of the lazily-built list, or the layout switches between +/// its narrow and wide arrangements. +class BenchmarkForm extends ChangeNotifier { + BenchmarkForm({ + String url = defaultUrl, + int concurrency = 4, + int totalRequests = 40, + BenchmarkType type = BenchmarkType.preCached, + HttpClientOption? clientOption, + }) : urlController = TextEditingController(text: url), + concurrencyController = + TextEditingController(text: concurrency.toString()), + requestsController = + TextEditingController(text: totalRequests.toString()), + _type = type, + _clientOption = clientOption ?? kHttpClientOptions.first; + + static const String defaultUrl = + 'https://download.samplelib.com/mp3/sample-15s.mp3'; + + final TextEditingController urlController; + final TextEditingController concurrencyController; + final TextEditingController requestsController; + + BenchmarkType _type; + HttpClientOption _clientOption; + String? _error; + + BenchmarkType get type => _type; + + set type(BenchmarkType value) { + if (_type == value) return; + _type = value; + notifyListeners(); + } + + HttpClientOption get clientOption => _clientOption; + + set clientOption(HttpClientOption value) { + if (_clientOption == value) return; + _clientOption = value; + notifyListeners(); + } + + /// Validation message from the last [buildConfig] attempt, if it failed. + String? get error => _error; + + /// Validates the current inputs and returns a runnable [BenchmarkConfig], + /// or null after recording [error]. + BenchmarkConfig? buildConfig() { + final url = urlController.text.trim(); + final concurrency = int.tryParse(concurrencyController.text.trim()); + final totalRequests = int.tryParse(requestsController.text.trim()); + + final error = BenchmarkConfig.validate( + url: url, + concurrency: concurrency, + totalRequests: totalRequests, + ); + if (error != _error) { + _error = error; + notifyListeners(); + } + if (error != null) return null; + + return BenchmarkConfig( + sourceUrl: Uri.parse(url), + concurrency: concurrency!, + totalRequests: totalRequests!, + type: _type, + clientOption: _clientOption, + ); + } + + @override + void dispose() { + urlController.dispose(); + concurrencyController.dispose(); + requestsController.dispose(); + super.dispose(); + } +} diff --git a/benchmarker/lib/src/ui/benchmark_page.dart b/benchmarker/lib/src/ui/benchmark_page.dart index a69dded..a40817f 100644 --- a/benchmarker/lib/src/ui/benchmark_page.dart +++ b/benchmarker/lib/src/ui/benchmark_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../benchmark/benchmark_controller.dart'; +import 'benchmark_form.dart'; import 'widgets/cache_progress_panel.dart'; import 'widgets/config_panel.dart'; import 'widgets/log_panel.dart'; @@ -17,9 +18,14 @@ class BenchmarkPage extends StatefulWidget { class _BenchmarkPageState extends State { final BenchmarkController _controller = BenchmarkController(); + /// Owned by the page so the inputs outlive the config panel, which the + /// lazily-built lists dispose whenever it scrolls out of view. + final BenchmarkForm _form = BenchmarkForm(); + @override void dispose() { _controller.dispose(); + _form.dispose(); super.dispose(); } @@ -72,6 +78,7 @@ class _BenchmarkPageState extends State { builder: (context, _) { final isBusy = _controller.phase.isBusy; final config = ConfigPanel( + form: _form, isBusy: isBusy, canCancel: isBusy && _controller.phase != BenchmarkPhase.cancelling, onRun: _controller.start, diff --git a/benchmarker/lib/src/ui/widgets/config_panel.dart b/benchmarker/lib/src/ui/widgets/config_panel.dart index 0e417d6..8a78506 100644 --- a/benchmarker/lib/src/ui/widgets/config_panel.dart +++ b/benchmarker/lib/src/ui/widgets/config_panel.dart @@ -3,73 +3,33 @@ import 'package:flutter/services.dart'; import '../../benchmark/benchmark_config.dart'; import '../../benchmark/http_client_builder.dart'; +import '../benchmark_form.dart'; import 'section_card.dart'; /// The benchmark inputs: source URL, concurrency, total requests, run type and /// http client implementation. -class ConfigPanel extends StatefulWidget { +/// +/// All state lives in [form], which the page owns, so the inputs survive this +/// widget being disposed and rebuilt. +class ConfigPanel extends StatelessWidget { const ConfigPanel({ super.key, + required this.form, required this.isBusy, required this.canCancel, required this.onRun, required this.onCancel, }); + final BenchmarkForm form; final bool isBusy; final bool canCancel; final ValueChanged onRun; final VoidCallback onCancel; - @override - State createState() => _ConfigPanelState(); -} - -class _ConfigPanelState extends State { - static const String _defaultUrl = - 'https://download.samplelib.com/mp3/sample-15s.mp3'; - - final TextEditingController _urlController = - TextEditingController(text: _defaultUrl); - final TextEditingController _concurrencyController = - TextEditingController(text: '4'); - final TextEditingController _requestsController = - TextEditingController(text: '40'); - - BenchmarkType _type = BenchmarkType.preCached; - HttpClientOption _clientOption = kHttpClientOptions.first; - String? _error; - - @override - void dispose() { - _urlController.dispose(); - _concurrencyController.dispose(); - _requestsController.dispose(); - super.dispose(); - } - void _run() { - final url = _urlController.text.trim(); - final concurrency = int.tryParse(_concurrencyController.text.trim()); - final totalRequests = int.tryParse(_requestsController.text.trim()); - - final error = BenchmarkConfig.validate( - url: url, - concurrency: concurrency, - totalRequests: totalRequests, - ); - setState(() => _error = error); - if (error != null) return; - - widget.onRun( - BenchmarkConfig( - sourceUrl: Uri.parse(url), - concurrency: concurrency!, - totalRequests: totalRequests!, - type: _type, - clientOption: _clientOption, - ), - ); + final config = form.buildConfig(); + if (config != null) onRun(config); } @override @@ -78,142 +38,143 @@ class _ConfigPanelState extends State { return SectionCard( title: 'Configuration', subtitle: 'Requests are divided evenly between worker isolates.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: _urlController, - enabled: !widget.isBusy, - keyboardType: TextInputType.url, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Source URL', - border: OutlineInputBorder(), - isDense: true, + child: ListenableBuilder( + listenable: form, + builder: (context, _) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: form.urlController, + enabled: !isBusy, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Source URL', + border: OutlineInputBorder(), + isDense: true, + ), ), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: TextField( - controller: _concurrencyController, - enabled: !widget.isBusy, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - labelText: 'Concurrency (workers)', - border: OutlineInputBorder(), - isDense: true, + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: form.concurrencyController, + enabled: !isBusy, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Concurrency (workers)', + border: OutlineInputBorder(), + isDense: true, + ), ), ), - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _requestsController, - enabled: !widget.isBusy, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - labelText: 'Total requests', - border: OutlineInputBorder(), - isDense: true, + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: form.requestsController, + enabled: !isBusy, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Total requests', + border: OutlineInputBorder(), + isDense: true, + ), ), ), - ), - ], - ), - const SizedBox(height: 16), - Align( - alignment: Alignment.centerLeft, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SegmentedButton( - segments: [ - for (final type in BenchmarkType.values) - ButtonSegment( - value: type, - label: Text(type.label), - ), - ], - selected: {_type}, - onSelectionChanged: widget.isBusy - ? null - : (selection) => setState(() => _type = selection.first), - ), + ], ), - ), - const SizedBox(height: 8), - Text( - _type.description, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + segments: [ + for (final type in BenchmarkType.values) + ButtonSegment( + value: type, + label: Text(type.label), + ), + ], + selected: {form.type}, + onSelectionChanged: isBusy + ? null + : (selection) => form.type = selection.first, + ), + ), ), - ), - const SizedBox(height: 16), - InputDecorator( - decoration: const InputDecoration( - labelText: 'HTTP client (per worker isolate)', - border: OutlineInputBorder(), - isDense: true, + const SizedBox(height: 8), + Text( + form.type.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _clientOption, - isExpanded: true, + const SizedBox(height: 16), + InputDecorator( + decoration: const InputDecoration( + labelText: 'HTTP client (per worker isolate)', + border: OutlineInputBorder(), isDense: true, - items: [ - for (final option in kHttpClientOptions) - DropdownMenuItem( - value: option, - child: - Text(option.label, overflow: TextOverflow.ellipsis), - ), - ], - onChanged: widget.isBusy - ? null - : (option) => setState( - () => - _clientOption = option ?? kHttpClientOptions.first, - ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: form.clientOption, + isExpanded: true, + isDense: true, + items: [ + for (final option in kHttpClientOptions) + DropdownMenuItem( + value: option, + child: + Text(option.label, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: isBusy + ? null + : (option) => form.clientOption = + option ?? kHttpClientOptions.first, + ), ), ), - ), - const SizedBox(height: 4), - Text( - _clientOption.description, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - if (_error != null) ...[ - const SizedBox(height: 12), + const SizedBox(height: 4), Text( - _error!, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.error), - ), - ], - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: widget.isBusy ? null : _run, - icon: const Icon(Icons.play_arrow), - label: const Text('Run benchmark'), - ), + form.clientOption.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: widget.canCancel ? widget.onCancel : null, - icon: const Icon(Icons.stop), - label: const Text('Cancel'), + ), + if (form.error case final error?) ...[ + const SizedBox(height: 12), + Text( + error, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.error), ), ], - ), - ], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: isBusy ? null : _run, + icon: const Icon(Icons.play_arrow), + label: const Text('Run benchmark'), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: canCancel ? onCancel : null, + icon: const Icon(Icons.stop), + label: const Text('Cancel'), + ), + ], + ), + ], + ), ), ); } diff --git a/benchmarker/test/benchmark_page_test.dart b/benchmarker/test/benchmark_page_test.dart new file mode 100644 index 0000000..843314b --- /dev/null +++ b/benchmarker/test/benchmark_page_test.dart @@ -0,0 +1,73 @@ +import 'package:benchmarker/src/benchmark/benchmark_config.dart'; +import 'package:benchmarker/src/benchmark/http_client_builder.dart'; +import 'package:benchmarker/src/ui/benchmark_page.dart'; +import 'package:benchmarker/src/ui/widgets/config_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('configuration survives the panel scrolling out of view', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: BenchmarkPage())); + + Future tapVisible(Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + // The URL field is the first text field on the page, followed by the + // concurrency and total request fields. + await tester.enterText( + find.byType(TextField).first, + 'https://example.com/custom.bin', + ); + await tester.enterText(find.byType(TextField).at(1), '7'); + await tester.enterText(find.byType(TextField).at(2), '21'); + await tapVisible(find.text(BenchmarkType.direct.label)); + + final selectedClient = kHttpClientOptions[1]; + await tapVisible(find.byType(DropdownButton)); + await tapVisible(find.text(selectedClient.label).last); + + // Scroll far enough that the panel leaves the list's cache extent and is + // disposed, then scroll back. + await tester.drag(find.byType(ListView), const Offset(0, -3000)); + await tester.pumpAndSettle(); + expect(find.byType(ConfigPanel), findsNothing); + + await tester.drag(find.byType(ListView), const Offset(0, 3000)); + await tester.pumpAndSettle(); + expect(find.byType(ConfigPanel), findsOneWidget); + + expect( + tester.widget(find.byType(TextField).first).controller!.text, + 'https://example.com/custom.bin', + ); + expect( + tester.widget(find.byType(TextField).at(1)).controller!.text, + '7', + ); + expect( + tester.widget(find.byType(TextField).at(2)).controller!.text, + '21', + ); + expect( + tester + .widget>( + find.byType(SegmentedButton), + ) + .selected, + {BenchmarkType.direct}, + ); + expect( + tester + .widget>( + find.byType(DropdownButton), + ) + .value, + selectedClient, + ); + }); +} From ff33e86a933fc503d9f1001fec45c409640c172c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:25:53 +0000 Subject: [PATCH 3/5] Add a range slider for benchmarking partial responses Requests can now ask for a byte range instead of the whole body. A range slider selects the portion of the source to request; because a range has to be expressed in bytes, a "Fetch length" probe resolves the source's size first (HEAD, falling back to a one-byte range request, which also reports whether the server honors Range at all). The probed length is dropped when the URL changes so a stale size cannot be applied to another source. The selected range travels to the workers on the job command and is sent as a Range header; the received byte count is verified against Content-Length just as it is for a full response. A selection covering the whole source sends no header, keeping full-body runs identical to before, and an empty selection is rejected before the run starts. If a range request is answered with anything other than 206, the run logs a warning once. Tests cover the range math, the form's probe and selection states, the panel wiring, a worker issuing a real range request, and end-to-end partial runs both direct and through the cache server. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JvD7reUpdgiJeHaJz2XSE --- benchmarker/README.md | 21 ++- .../lib/src/benchmark/benchmark_config.dart | 74 +++++++++ .../src/benchmark/benchmark_controller.dart | 21 +++ .../lib/src/benchmark/benchmark_worker.dart | 18 ++- .../lib/src/benchmark/source_probe.dart | 83 ++++++++++ .../lib/src/benchmark/worker_protocol.dart | 5 + benchmarker/lib/src/ui/benchmark_form.dart | 142 +++++++++++++++++- .../lib/src/ui/widgets/config_panel.dart | 122 +++++++++++++++ benchmarker/test/benchmark_config_test.dart | 35 +++++ .../test/benchmark_controller_test.dart | 78 +++++++++- benchmarker/test/benchmark_form_test.dart | 132 ++++++++++++++++ benchmarker/test/config_panel_test.dart | 97 ++++++++++++ benchmarker/test/worker_pool_test.dart | 60 +++++++- 13 files changed, 874 insertions(+), 14 deletions(-) create mode 100644 benchmarker/lib/src/benchmark/source_probe.dart create mode 100644 benchmarker/test/benchmark_form_test.dart create mode 100644 benchmarker/test/config_panel_test.dart diff --git a/benchmarker/README.md b/benchmarker/README.md index 7a639b2..aac98d6 100644 --- a/benchmarker/README.md +++ b/benchmarker/README.md @@ -29,6 +29,21 @@ respawned when the concurrency or the client implementation changes. | Source URL | The remote URL under test. | | Concurrency | Number of worker isolates. | | Total requests | Requests issued in total, divided evenly between the workers (the remainder goes to the lowest-numbered workers). | +| Byte range | Portion of the source each request asks for, via a range slider. | + +### Partial responses + +The range slider needs the source's size to express a selection in bytes, so +**Fetch length** probes the URL first — a `HEAD`, falling back to a one-byte +range request, which also reveals whether the server honors `Range` at all. The +probed length is dropped as soon as the URL is edited, so a stale size can never +be applied to a different source. + +Each request then carries `Range: bytes=-`, and the response's byte +count is verified against its `Content-Length` exactly as a full response is. A +selection covering the whole source sends no `Range` header, and a run whose +range requests come back as anything other than `206 Partial Content` logs a +warning once — the server is likely ignoring the range. ### Run types @@ -77,9 +92,13 @@ lib/ benchmark_stats.dart timing/percentile accumulation benchmark_worker.dart worker isolate entry point http_client_builder.dart selectable http client implementations + source_probe.dart content-length / range-support probe worker_pool.dart long-lived isolate pool worker_protocol.dart messages exchanged with the workers - src/ui/ page and panels + src/ui/ + benchmark_form.dart input state, owned by the page + benchmark_page.dart page layout + widgets/ config, progress, stats and log panels ``` ## Tests diff --git a/benchmarker/lib/src/benchmark/benchmark_config.dart b/benchmarker/lib/src/benchmark/benchmark_config.dart index c8f580d..dcd3b7b 100644 --- a/benchmarker/lib/src/benchmark/benchmark_config.dart +++ b/benchmarker/lib/src/benchmark/benchmark_config.dart @@ -30,6 +30,76 @@ enum BenchmarkType { bool get usesCacheServer => this != BenchmarkType.direct; } +/// An inclusive byte range, matching HTTP `Range` semantics. +class ByteRange { + const ByteRange(this.start, this.end) + : assert(start >= 0), + assert(end >= start); + + /// First byte requested, inclusive. + final int start; + + /// Last byte requested, inclusive. + final int end; + + /// Number of bytes the response should carry. + int get length => end - start + 1; + + /// Value for the `Range` request header. + String get header => 'bytes=$start-$end'; + + /// Builds a range from two fractions of [contentLength], as produced by the + /// range slider. Returns null when the selection covers the whole source — + /// no `Range` header is sent then — or when it is empty. + static ByteRange? fromFractions( + double startFraction, + double endFraction, + int contentLength, + ) { + final bounds = resolveBounds(startFraction, endFraction, contentLength); + if (bounds == null) return null; + if (bounds.start <= 0 && bounds.endExclusive >= contentLength) { + return null; // Whole body. + } + if (bounds.endExclusive <= bounds.start) return null; // Empty selection. + return ByteRange(bounds.start, bounds.endExclusive - 1); + } + + /// Whether the given fractions select no bytes at all. + static bool isEmptySelection( + double startFraction, + double endFraction, + int contentLength, + ) { + final bounds = resolveBounds(startFraction, endFraction, contentLength); + return bounds != null && bounds.endExclusive <= bounds.start; + } + + /// Resolves slider fractions to absolute byte offsets, or null when the + /// content length is unknown. + static ({int start, int endExclusive})? resolveBounds( + double startFraction, + double endFraction, + int contentLength, + ) { + if (contentLength <= 0) return null; + return ( + start: (startFraction.clamp(0.0, 1.0) * contentLength).floor(), + endExclusive: (endFraction.clamp(0.0, 1.0) * contentLength).round(), + ); + } + + @override + bool operator ==(Object other) => + other is ByteRange && other.start == start && other.end == end; + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() => 'bytes $start-$end'; +} + /// A single benchmark run's inputs. class BenchmarkConfig { const BenchmarkConfig({ @@ -38,6 +108,7 @@ class BenchmarkConfig { required this.totalRequests, required this.type, required this.clientOption, + this.range, }); /// The remote URL under test. @@ -54,6 +125,9 @@ class BenchmarkConfig { /// Which [HttpClientBuilder] the workers use. final HttpClientOption clientOption; + /// Byte range each request asks for, or null to request the full response. + final ByteRange? range; + /// Splits [totalRequests] across [concurrency] workers as evenly as possible. /// /// The remainder is handed to the lowest-numbered workers, so the returned diff --git a/benchmarker/lib/src/benchmark/benchmark_controller.dart b/benchmarker/lib/src/benchmark/benchmark_controller.dart index 0e50bea..1797b98 100644 --- a/benchmarker/lib/src/benchmark/benchmark_controller.dart +++ b/benchmarker/lib/src/benchmark/benchmark_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http_cache_stream/http_cache_stream.dart'; @@ -51,6 +52,7 @@ class BenchmarkController extends ChangeNotifier { bool _cancelRequested = false; bool _dirty = false; bool _warnedUnverified = false; + bool _warnedRangeIgnored = false; bool _disposed = false; BenchmarkPhase _phase = BenchmarkPhase.idle; @@ -89,6 +91,7 @@ class BenchmarkController extends ChangeNotifier { _config = config; _cancelRequested = false; _warnedUnverified = false; + _warnedRangeIgnored = false; _problemCounts.clear(); _accumulator = StatsAccumulator(config.totalRequests); _stats = BenchmarkStats.empty(config.totalRequests); @@ -103,6 +106,12 @@ class BenchmarkController extends ChangeNotifier { '${config.totalRequests} requests · ${config.concurrency} workers · ' '${config.clientOption.label}', ); + if (config.range case final range?) { + _log( + 'Partial responses: Range ${range.header} ' + '(${formatBytes(range.length)} per request).', + ); + } _setPhase(BenchmarkPhase.preparing); _startTicker(); @@ -266,6 +275,7 @@ class BenchmarkController extends ChangeNotifier { url: target.toString(), requestCount: count, firstSequence: sequence, + rangeHeader: config.range?.header, ), ); sequence += count; @@ -366,6 +376,17 @@ class BenchmarkController extends ChangeNotifier { /// Logs problems without flooding the log: the first occurrence of each /// distinct problem is logged, then only at power-of-ten milestones. void _noteResult(RequestResult result) { + if (_config?.range != null && + !_warnedRangeIgnored && + result.statusCode != null && + result.statusCode != HttpStatus.partialContent) { + _warnedRangeIgnored = true; + _log( + 'Range request answered with HTTP ${result.statusCode} instead of 206; ' + 'the server may be ignoring the requested range.', + level: LogLevel.warning, + ); + } if (result.outcome == RequestOutcome.unverified && !_warnedUnverified) { _warnedUnverified = true; _log( diff --git a/benchmarker/lib/src/benchmark/benchmark_worker.dart b/benchmarker/lib/src/benchmark/benchmark_worker.dart index 949d63e..afe2bd0 100644 --- a/benchmarker/lib/src/benchmark/benchmark_worker.dart +++ b/benchmarker/lib/src/benchmark/benchmark_worker.dart @@ -99,7 +99,11 @@ class _BenchmarkWorker { try { for (var i = 0; i < job.requestCount; i++) { if (_cancelRequested) break; - final result = await _executeRequest(uri, job.firstSequence + i); + final result = await _executeRequest( + uri, + job.firstSequence + i, + job.rangeHeader, + ); if (result == null) break; // Abandoned mid-response by a cancel. _pending.add(result); if (_pending.length >= _maxBatchSize || @@ -122,14 +126,22 @@ class _BenchmarkWorker { /// /// Returns null if the request was abandoned by a cancel before the response /// was fully read; such a request is not a measurement and is not reported. - Future _executeRequest(Uri uri, int sequence) async { + Future _executeRequest( + Uri uri, + int sequence, + String? rangeHeader, + ) async { final stopwatch = Stopwatch()..start(); int? headerMicros; int? firstByteMicros; var bytesReceived = 0; try { - final response = await _client.send(http.Request('GET', uri)); + final request = http.Request('GET', uri); + if (rangeHeader != null) { + request.headers['range'] = rangeHeader; + } + final response = await _client.send(request); headerMicros = stopwatch.elapsedMicroseconds; await for (final chunk in response.stream) { diff --git a/benchmarker/lib/src/benchmark/source_probe.dart b/benchmarker/lib/src/benchmark/source_probe.dart new file mode 100644 index 0000000..b8e3f37 --- /dev/null +++ b/benchmarker/lib/src/benchmark/source_probe.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; + +/// What a probe learned about the source URL. +class SourceInfo { + const SourceInfo({required this.contentLength, required this.acceptsRanges}); + + /// Total size of the source in bytes, or null if the server did not report + /// one. + final int? contentLength; + + /// Whether the server advertised (or demonstrated) support for byte ranges. + final bool acceptsRanges; +} + +/// Fetches the size of [url] so partial-response ranges can be expressed in +/// bytes before any benchmark request is issued. +/// +/// Tries a `HEAD` first and falls back to a one-byte range request, which also +/// reveals whether the server honors `Range` at all. +Future probeSource( + Uri url, { + http.Client? client, + Duration timeout = const Duration(seconds: 20), +}) async { + final httpClient = client ?? http.Client(); + try { + try { + final response = await httpClient.head(url).timeout(timeout); + if (_isSuccess(response.statusCode) && (response.contentLength ?? 0) > 0) { + return SourceInfo( + contentLength: response.contentLength, + acceptsRanges: _advertisesRanges(response.headers), + ); + } + } on Exception { + // Not every server implements HEAD; fall through to the range request. + } + + final request = http.Request('GET', url) + ..headers[HttpHeaders.rangeHeader] = 'bytes=0-0'; + final response = await httpClient.send(request).timeout(timeout); + await response.stream.drain(); + + if (!_isSuccess(response.statusCode)) { + throw HttpException( + 'Source returned HTTP ${response.statusCode}.', + uri: url, + ); + } + + final total = _totalFromContentRange( + response.headers[HttpHeaders.contentRangeHeader], + ); + if (total != null) { + return SourceInfo(contentLength: total, acceptsRanges: true); + } + + // The range was ignored: the response is the whole entity. + return SourceInfo( + contentLength: response.contentLength, + acceptsRanges: _advertisesRanges(response.headers), + ); + } finally { + if (client == null) httpClient.close(); + } +} + +bool _isSuccess(int statusCode) => statusCode >= 200 && statusCode < 300; + +bool _advertisesRanges(Map headers) => + headers[HttpHeaders.acceptRangesHeader]?.toLowerCase().contains('bytes') ?? + false; + +/// Parses the total size out of a `Content-Range: bytes 0-0/1234` header. +/// Returns null for an unknown (`*`) or malformed total. +int? _totalFromContentRange(String? contentRange) { + if (contentRange == null) return null; + final slash = contentRange.lastIndexOf('/'); + if (slash < 0) return null; + return int.tryParse(contentRange.substring(slash + 1).trim()); +} diff --git a/benchmarker/lib/src/benchmark/worker_protocol.dart b/benchmarker/lib/src/benchmark/worker_protocol.dart index 2cf2913..5a6397f 100644 --- a/benchmarker/lib/src/benchmark/worker_protocol.dart +++ b/benchmarker/lib/src/benchmark/worker_protocol.dart @@ -30,6 +30,7 @@ class RunJobCommand extends WorkerCommand { required this.url, required this.requestCount, required this.firstSequence, + this.rangeHeader, }); final int jobId; @@ -41,6 +42,10 @@ class RunJobCommand extends WorkerCommand { /// Global index of this job's first request, used to label results. final int firstSequence; + + /// Value for the `Range` header, e.g. `bytes=0-1023`. Null requests the full + /// response. + final String? rangeHeader; } /// Stop the active job early. The worker still reports a [JobDoneEvent]. diff --git a/benchmarker/lib/src/ui/benchmark_form.dart b/benchmarker/lib/src/ui/benchmark_form.dart index 9a098cf..f42f442 100644 --- a/benchmarker/lib/src/ui/benchmark_form.dart +++ b/benchmarker/lib/src/ui/benchmark_form.dart @@ -1,7 +1,12 @@ +import 'package:flutter/material.dart' show RangeValues; import 'package:flutter/widgets.dart'; import '../benchmark/benchmark_config.dart'; import '../benchmark/http_client_builder.dart'; +import '../benchmark/source_probe.dart'; + +/// State of the source content-length probe backing the range slider. +enum ProbeStatus { idle, loading, ready, failed } /// Holds the benchmark inputs. /// @@ -16,13 +21,16 @@ class BenchmarkForm extends ChangeNotifier { int totalRequests = 40, BenchmarkType type = BenchmarkType.preCached, HttpClientOption? clientOption, + this.probe = probeSource, }) : urlController = TextEditingController(text: url), concurrencyController = TextEditingController(text: concurrency.toString()), requestsController = TextEditingController(text: totalRequests.toString()), _type = type, - _clientOption = clientOption ?? kHttpClientOptions.first; + _clientOption = clientOption ?? kHttpClientOptions.first { + urlController.addListener(_onUrlChanged); + } static const String defaultUrl = 'https://download.samplelib.com/mp3/sample-15s.mp3'; @@ -31,10 +39,21 @@ class BenchmarkForm extends ChangeNotifier { final TextEditingController concurrencyController; final TextEditingController requestsController; + /// Injectable for tests; defaults to a real network probe. + final Future Function(Uri url) probe; + BenchmarkType _type; HttpClientOption _clientOption; String? _error; + RangeValues _rangeFraction = const RangeValues(0, 1); + ProbeStatus _probeStatus = ProbeStatus.idle; + String? _probeError; + int? _contentLength; + bool _acceptsRanges = false; + String? _probedUrl; + int _probeToken = 0; + BenchmarkType get type => _type; set type(BenchmarkType value) { @@ -54,6 +73,118 @@ class BenchmarkForm extends ChangeNotifier { /// Validation message from the last [buildConfig] attempt, if it failed. String? get error => _error; + // --------------------------------------------------------------------------- + // Source probe + // --------------------------------------------------------------------------- + + ProbeStatus get probeStatus => _probeStatus; + + /// Why the last probe failed, if it did. + String? get probeError => _probeError; + + /// Source size in bytes, once probed. Null until then. + int? get contentLength => _contentLength; + + /// Whether the source advertised support for byte ranges. + bool get acceptsRanges => _acceptsRanges; + + /// Whether a partial range can be selected, which needs a known size. + bool get canSelectRange => (_contentLength ?? 0) > 0; + + /// Fetches the source's content length so ranges can be chosen in bytes. + Future fetchSourceLength() async { + final url = urlController.text.trim(); + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + _probeStatus = ProbeStatus.failed; + _probeError = 'Enter a valid absolute source URL first.'; + notifyListeners(); + return; + } + + final token = ++_probeToken; + _probeStatus = ProbeStatus.loading; + _probeError = null; + notifyListeners(); + + try { + final info = await probe(uri); + if (token != _probeToken) return; // Superseded by a newer probe. + _contentLength = info.contentLength; + _acceptsRanges = info.acceptsRanges; + _probedUrl = url; + _probeStatus = ProbeStatus.ready; + _probeError = info.contentLength == null + ? 'The source did not report a Content-Length.' + : null; + _rangeFraction = const RangeValues(0, 1); + } catch (e) { + if (token != _probeToken) return; + _contentLength = null; + _acceptsRanges = false; + _probeStatus = ProbeStatus.failed; + _probeError = '$e'; + } + notifyListeners(); + } + + /// Drops a probed length once the URL no longer matches it, so a stale size + /// can never be applied to a different source. + void _onUrlChanged() { + if (_probedUrl == null || _probedUrl == urlController.text.trim()) return; + _probedUrl = null; + _contentLength = null; + _acceptsRanges = false; + _probeStatus = ProbeStatus.idle; + _probeError = null; + _rangeFraction = const RangeValues(0, 1); + notifyListeners(); + } + + // --------------------------------------------------------------------------- + // Range selection + // --------------------------------------------------------------------------- + + /// Selected portion of the source, as fractions from 0 to 1. + RangeValues get rangeFraction => _rangeFraction; + + set rangeFraction(RangeValues value) { + final start = value.start.clamp(0.0, 1.0); + final end = value.end.clamp(start, 1.0); + if (start == _rangeFraction.start && end == _rangeFraction.end) return; + _rangeFraction = RangeValues(start, end); + notifyListeners(); + } + + /// The byte range requests should ask for, or null for the full response. + ByteRange? selectedRange() { + final length = _contentLength; + if (length == null || length <= 0) return null; + return ByteRange.fromFractions( + _rangeFraction.start, + _rangeFraction.end, + length, + ); + } + + /// Whether the selection resolves to zero bytes. + bool get isEmptySelection { + final length = _contentLength; + if (length == null || length <= 0) return false; + return ByteRange.isEmptySelection( + _rangeFraction.start, + _rangeFraction.end, + length, + ); + } + + /// Whether the whole source is requested, so no `Range` header is sent. + bool get isFullRange => !isEmptySelection && selectedRange() == null; + + // --------------------------------------------------------------------------- + // Config + // --------------------------------------------------------------------------- + /// Validates the current inputs and returns a runnable [BenchmarkConfig], /// or null after recording [error]. BenchmarkConfig? buildConfig() { @@ -61,11 +192,16 @@ class BenchmarkForm extends ChangeNotifier { final concurrency = int.tryParse(concurrencyController.text.trim()); final totalRequests = int.tryParse(requestsController.text.trim()); - final error = BenchmarkConfig.validate( + var error = BenchmarkConfig.validate( url: url, concurrency: concurrency, totalRequests: totalRequests, ); + final range = error == null ? selectedRange() : null; + if (error == null && isEmptySelection) { + error = 'The selected range is empty.'; + } + if (error != _error) { _error = error; notifyListeners(); @@ -78,11 +214,13 @@ class BenchmarkForm extends ChangeNotifier { totalRequests: totalRequests!, type: _type, clientOption: _clientOption, + range: range, ); } @override void dispose() { + urlController.removeListener(_onUrlChanged); urlController.dispose(); concurrencyController.dispose(); requestsController.dispose(); diff --git a/benchmarker/lib/src/ui/widgets/config_panel.dart b/benchmarker/lib/src/ui/widgets/config_panel.dart index 8a78506..c02b1b9 100644 --- a/benchmarker/lib/src/ui/widgets/config_panel.dart +++ b/benchmarker/lib/src/ui/widgets/config_panel.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import '../../benchmark/benchmark_config.dart'; import '../../benchmark/http_client_builder.dart'; +import '../../util/formatting.dart'; import '../benchmark_form.dart'; import 'section_card.dart'; @@ -86,6 +87,8 @@ class ConfigPanel extends StatelessWidget { ), ], ), + const SizedBox(height: 8), + _RangeSelector(form: form, isBusy: isBusy), const SizedBox(height: 16), Align( alignment: Alignment.centerLeft, @@ -179,3 +182,122 @@ class ConfigPanel extends StatelessWidget { ); } } + +/// Selects the byte range every request asks for. +/// +/// The slider needs the source's size to express a range in bytes, so it stays +/// disabled until the content length has been fetched. +class _RangeSelector extends StatelessWidget { + const _RangeSelector({required this.form, required this.isBusy}); + + final BenchmarkForm form; + final bool isBusy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final contentLength = form.contentLength; + final enabled = !isBusy && form.canSelectRange; + final bounds = contentLength == null + ? null + : ByteRange.resolveBounds( + form.rangeFraction.start, + form.rangeFraction.end, + contentLength, + ); + + final String detail; + if (contentLength == null) { + detail = 'Fetch the source length to request partial responses.'; + } else if (form.isEmptySelection) { + detail = 'Empty selection — widen the range.'; + } else if (form.isFullRange) { + detail = 'Full response · ${formatBytes(contentLength)} ' + '($contentLength bytes), no Range header'; + } else { + final range = form.selectedRange()!; + detail = 'Range ${range.header} · ${formatBytes(range.length)} ' + '(${range.length} bytes, ' + '${formatPercent(range.length / contentLength)} of source)'; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Requested byte range', + style: theme.textTheme.labelLarge, + ), + ), + if (form.probeStatus == ProbeStatus.loading) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + TextButton.icon( + onPressed: isBusy ? null : form.fetchSourceLength, + icon: const Icon(Icons.straighten, size: 18), + label: Text( + contentLength == null ? 'Fetch length' : 'Refresh length', + ), + ), + ], + ), + RangeSlider( + values: form.rangeFraction, + divisions: 200, + labels: RangeLabels( + _thumbLabel(bounds?.start, contentLength, form.rangeFraction.start), + _thumbLabel( + bounds?.endExclusive, + contentLength, + form.rangeFraction.end, + ), + ), + onChanged: enabled ? (values) => form.rangeFraction = values : null, + ), + Text( + detail, + style: theme.textTheme.bodySmall?.copyWith( + color: form.isEmptySelection + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + ), + ), + if (form.probeError case final probeError?) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + probeError, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.error), + ), + ) + else if (contentLength != null && !form.acceptsRanges) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'The source did not advertise Accept-Ranges; it may answer with ' + 'the full body.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.tertiary, + ), + ), + ), + ], + ); + } + + static String _thumbLabel(int? offset, int? contentLength, double fraction) { + if (offset == null || contentLength == null) return formatPercent(fraction); + return formatBytes(offset); + } +} diff --git a/benchmarker/test/benchmark_config_test.dart b/benchmarker/test/benchmark_config_test.dart index cc4df25..e1f949f 100644 --- a/benchmarker/test/benchmark_config_test.dart +++ b/benchmarker/test/benchmark_config_test.dart @@ -41,6 +41,41 @@ void main() { }); }); + group('ByteRange.fromFractions', () { + test('resolves fractions to an inclusive byte range', () { + final range = ByteRange.fromFractions(0.25, 0.5, 1000)!; + expect(range.start, 250); + expect(range.end, 499); + expect(range.length, 250); + expect(range.header, 'bytes=250-499'); + }); + + test('returns null for a full selection so no Range header is sent', () { + expect(ByteRange.fromFractions(0, 1, 1000), isNull); + // Rounds up to the last byte, which is still the whole body. + expect(ByteRange.fromFractions(0, 0.9999, 1000), isNull); + }); + + test('returns null for an empty selection', () { + expect(ByteRange.fromFractions(0.5, 0.5, 1000), isNull); + expect(ByteRange.isEmptySelection(0.5, 0.5, 1000), isTrue); + expect(ByteRange.isEmptySelection(0, 1, 1000), isFalse); + expect(ByteRange.isEmptySelection(0, 0.9999, 1000), isFalse); + }); + + test('keeps the tail range within the source', () { + final range = ByteRange.fromFractions(0.5, 1, 1001)!; + expect(range.start, 500); + expect(range.end, 1000); + expect(range.length, 501); + }); + + test('returns null when the content length is unknown or zero', () { + expect(ByteRange.fromFractions(0.1, 0.2, 0), isNull); + expect(ByteRange.resolveBounds(0.1, 0.2, 0), isNull); + }); + }); + group('validate', () { test('accepts a well-formed config', () { expect( diff --git a/benchmarker/test/benchmark_controller_test.dart b/benchmarker/test/benchmark_controller_test.dart index d24b896..a214d8b 100644 --- a/benchmarker/test/benchmark_controller_test.dart +++ b/benchmarker/test/benchmark_controller_test.dart @@ -7,6 +7,19 @@ import 'package:benchmarker/src/benchmark/http_client_builder.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_cache_stream/http_cache_stream.dart'; +/// Parses a single `bytes=start-end` range against a known total length. +({int start, int end})? _parseRange(String? header, int totalLength) { + if (header == null || !header.startsWith('bytes=')) return null; + final parts = header.substring('bytes='.length).split('-'); + if (parts.length != 2) return null; + final start = int.tryParse(parts[0]); + final end = int.tryParse(parts[1]) ?? totalLength - 1; + if (start == null || start < 0 || end >= totalLength || end < start) { + return null; + } + return (start: start, end: end); +} + /// End-to-end coverage of a benchmark run: a real origin server, a real /// [HttpCacheManager] with its local cache server, and real worker isolates. void main() { @@ -23,9 +36,23 @@ void main() { unawaited(() async { await for (final request in origin) { originRequests++; - request.response.headers.contentLength = payload.length; request.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); - request.response.add(payload); + final range = _parseRange( + request.headers.value(HttpHeaders.rangeHeader), + payload.length, + ); + if (range == null) { + request.response.headers.contentLength = payload.length; + request.response.add(payload); + } else { + request.response.statusCode = HttpStatus.partialContent; + request.response.headers.contentLength = range.end - range.start + 1; + request.response.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes ${range.start}-${range.end}/${payload.length}', + ); + request.response.add(payload.sublist(range.start, range.end + 1)); + } await request.response.close(); } }()); @@ -50,13 +77,19 @@ void main() { } }); - BenchmarkConfig configFor(BenchmarkType type, {int workers = 2, int total = 4}) { + BenchmarkConfig configFor( + BenchmarkType type, { + int workers = 2, + int total = 4, + ByteRange? range, + }) { return BenchmarkConfig( sourceUrl: sourceUrl, concurrency: workers, totalRequests: total, type: type, clientOption: kHttpClientOptions.first, + range: range, ); } @@ -120,6 +153,45 @@ void main() { ); }); + test('direct run requests only the selected byte range', () async { + const range = ByteRange(1024, 5119); + await controller.start(configFor(BenchmarkType.direct, range: range)); + + expect(controller.phase, BenchmarkPhase.finished); + final stats = controller.stats!; + expect(stats.completed, 4); + expect(stats.succeeded, 4); + expect(stats.errorCount, 0); + expect(stats.totalBytes, 4 * range.length); + expect(stats.avgBytesPerRequest, range.length.toDouble()); + expect( + controller.logs.map((entry) => entry.message), + contains(contains('Range bytes=1024-5119')), + ); + }); + + test('pre-cached run serves the selected byte range from the cache', + () async { + const range = ByteRange(4096, 8191); + await controller.start(configFor(BenchmarkType.preCached, range: range)); + + expect(controller.phase, BenchmarkPhase.finished); + expect(controller.cacheState!.isComplete, isTrue); + + final stats = controller.stats!; + expect(stats.completed, 4); + expect(stats.succeeded, 4); + expect(stats.errorCount, 0); + expect(stats.totalBytes, 4 * range.length); + // Only the pre-cache download reached the origin. + expect(originRequests, 1); + // A 206 was returned, so no "range ignored" warning was logged. + expect( + controller.logs.map((entry) => entry.message), + isNot(contains(contains('may be ignoring the requested range'))), + ); + }); + test('the worker pool is reused between runs with the same settings', () async { await controller.start(configFor(BenchmarkType.direct, total: 2)); diff --git a/benchmarker/test/benchmark_form_test.dart b/benchmarker/test/benchmark_form_test.dart new file mode 100644 index 0000000..d6c177d --- /dev/null +++ b/benchmarker/test/benchmark_form_test.dart @@ -0,0 +1,132 @@ +import 'package:benchmarker/src/benchmark/source_probe.dart'; +import 'package:benchmarker/src/ui/benchmark_form.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BenchmarkForm _form({ + int? contentLength = 1000, + bool acceptsRanges = true, + Object? failWith, +}) { + return BenchmarkForm( + url: 'https://example.com/file.bin', + probe: (uri) async { + if (failWith != null) throw failWith; + return SourceInfo( + contentLength: contentLength, + acceptsRanges: acceptsRanges, + ); + }, + ); +} + +void main() { + test('a range cannot be selected before the source length is known', () { + final form = _form(); + addTearDown(form.dispose); + + expect(form.canSelectRange, isFalse); + expect(form.selectedRange(), isNull); + expect(form.buildConfig()!.range, isNull); + }); + + test('fetching the length enables ranges and reports the source size', + () async { + final form = _form(contentLength: 2048); + addTearDown(form.dispose); + + await form.fetchSourceLength(); + + expect(form.probeStatus, ProbeStatus.ready); + expect(form.contentLength, 2048); + expect(form.acceptsRanges, isTrue); + expect(form.canSelectRange, isTrue); + // The whole source is selected, so no Range header is sent. + expect(form.isFullRange, isTrue); + expect(form.buildConfig()!.range, isNull); + }); + + test('a partial selection becomes the config range', () async { + final form = _form(contentLength: 1000); + addTearDown(form.dispose); + await form.fetchSourceLength(); + + form.rangeFraction = const RangeValues(0.25, 0.75); + + final range = form.buildConfig()!.range!; + expect(range.start, 250); + expect(range.end, 749); + expect(range.length, 500); + expect(range.header, 'bytes=250-749'); + }); + + test('an empty selection is rejected with an error', () async { + final form = _form(contentLength: 1000); + addTearDown(form.dispose); + await form.fetchSourceLength(); + + form.rangeFraction = const RangeValues(0.5, 0.5); + + expect(form.isEmptySelection, isTrue); + expect(form.buildConfig(), isNull); + expect(form.error, 'The selected range is empty.'); + }); + + test('changing the URL drops the probed length', () async { + final form = _form(contentLength: 1000); + addTearDown(form.dispose); + await form.fetchSourceLength(); + form.rangeFraction = const RangeValues(0.25, 0.75); + + form.urlController.text = 'https://example.com/other.bin'; + + expect(form.contentLength, isNull); + expect(form.canSelectRange, isFalse); + expect(form.probeStatus, ProbeStatus.idle); + expect(form.rangeFraction, const RangeValues(0, 1)); + expect(form.buildConfig()!.range, isNull); + }); + + test('a failed probe is reported and leaves ranges disabled', () async { + final form = _form(failWith: StateError('offline')); + addTearDown(form.dispose); + + await form.fetchSourceLength(); + + expect(form.probeStatus, ProbeStatus.failed); + expect(form.probeError, contains('offline')); + expect(form.canSelectRange, isFalse); + }); + + test('a source without a Content-Length is reported', () async { + final form = _form(contentLength: null, acceptsRanges: false); + addTearDown(form.dispose); + + await form.fetchSourceLength(); + + expect(form.probeStatus, ProbeStatus.ready); + expect(form.contentLength, isNull); + expect(form.canSelectRange, isFalse); + expect(form.probeError, contains('Content-Length')); + }); + + test('a stale probe does not overwrite a newer one', () async { + var pending = 0; + final form = BenchmarkForm( + url: 'https://example.com/file.bin', + probe: (uri) async { + final size = ++pending; + // The first call resolves last. + await Future.delayed(Duration(milliseconds: 40 ~/ size)); + return SourceInfo(contentLength: size * 1000, acceptsRanges: true); + }, + ); + addTearDown(form.dispose); + + final first = form.fetchSourceLength(); + final second = form.fetchSourceLength(); + await Future.wait([first, second]); + + expect(form.contentLength, 2000); + }); +} diff --git a/benchmarker/test/config_panel_test.dart b/benchmarker/test/config_panel_test.dart new file mode 100644 index 0000000..4f8fcad --- /dev/null +++ b/benchmarker/test/config_panel_test.dart @@ -0,0 +1,97 @@ +import 'package:benchmarker/src/benchmark/benchmark_config.dart'; +import 'package:benchmarker/src/benchmark/source_probe.dart'; +import 'package:benchmarker/src/ui/benchmark_form.dart'; +import 'package:benchmarker/src/ui/widgets/config_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('the range slider drives the requested byte range', + (tester) async { + final form = BenchmarkForm( + url: 'https://example.com/file.bin', + probe: (uri) async => + const SourceInfo(contentLength: 1000, acceptsRanges: true), + ); + addTearDown(form.dispose); + BenchmarkConfig? started; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ConfigPanel( + form: form, + isBusy: false, + canCancel: false, + onRun: (config) => started = config, + onCancel: () {}, + ), + ), + ), + ), + ); + + RangeSlider slider() => tester.widget(find.byType(RangeSlider)); + + // Without a known source length the slider is disabled. + expect(slider().onChanged, isNull); + expect( + find.text('Fetch the source length to request partial responses.'), + findsOneWidget, + ); + + await tester.tap(find.text('Fetch length')); + await tester.pumpAndSettle(); + + expect(slider().onChanged, isNotNull); + expect(find.textContaining('Full response'), findsOneWidget); + + // Selecting a sub-range shows it in bytes and sends it with the run. + form.rangeFraction = const RangeValues(0.25, 0.75); + await tester.pumpAndSettle(); + expect(find.textContaining('Range bytes=250-749'), findsOneWidget); + + await tester.tap(find.text('Run benchmark')); + await tester.pumpAndSettle(); + + expect(started!.range, const ByteRange(250, 749)); + }); + + testWidgets('an empty range selection blocks the run', (tester) async { + final form = BenchmarkForm( + url: 'https://example.com/file.bin', + probe: (uri) async => + const SourceInfo(contentLength: 1000, acceptsRanges: true), + ); + addTearDown(form.dispose); + var runs = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ConfigPanel( + form: form, + isBusy: false, + canCancel: false, + onRun: (_) => runs++, + onCancel: () {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Fetch length')); + await tester.pumpAndSettle(); + form.rangeFraction = const RangeValues(0.5, 0.5); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Run benchmark')); + await tester.pumpAndSettle(); + + expect(runs, 0); + expect(find.text('The selected range is empty.'), findsWidgets); + }); +} diff --git a/benchmarker/test/worker_pool_test.dart b/benchmarker/test/worker_pool_test.dart index e91d8e8..d10c9e1 100644 --- a/benchmarker/test/worker_pool_test.dart +++ b/benchmarker/test/worker_pool_test.dart @@ -6,8 +6,9 @@ import 'package:benchmarker/src/benchmark/worker_pool.dart'; import 'package:benchmarker/src/benchmark/worker_protocol.dart'; import 'package:flutter_test/flutter_test.dart'; -/// Serves a fixed payload with a correct `Content-Length`, plus endpoints for a -/// chunked (length-less) response and an error status. +/// Serves a fixed payload with a correct `Content-Length`, honoring single +/// byte ranges, plus endpoints for a chunked (length-less) response and an +/// error status. Future _startOrigin(List payload) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); unawaited(() async { @@ -19,8 +20,23 @@ Future _startOrigin(List payload) async { case '/error': request.response.statusCode = HttpStatus.notFound; default: - request.response.headers.contentLength = payload.length; - request.response.add(payload); + final range = _parseRange( + request.headers.value(HttpHeaders.rangeHeader), + payload.length, + ); + if (range == null) { + request.response.headers.contentLength = payload.length; + request.response.add(payload); + } else { + request.response.statusCode = HttpStatus.partialContent; + request.response.headers.contentLength = + range.end - range.start + 1; + request.response.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes ${range.start}-${range.end}/${payload.length}', + ); + request.response.add(payload.sublist(range.start, range.end + 1)); + } } await request.response.close(); } @@ -28,6 +44,19 @@ Future _startOrigin(List payload) async { return server; } +/// Parses a single `bytes=start-end` range against a known total length. +({int start, int end})? _parseRange(String? header, int totalLength) { + if (header == null || !header.startsWith('bytes=')) return null; + final parts = header.substring('bytes='.length).split('-'); + if (parts.length != 2) return null; + final start = int.tryParse(parts[0]); + final end = int.tryParse(parts[1]) ?? totalLength - 1; + if (start == null || start < 0 || end >= totalLength || end < start) { + return null; + } + return (start: start, end: end); +} + void main() { final payload = List.generate(64 * 1024, (index) => index % 256); late HttpServer origin; @@ -47,7 +76,11 @@ void main() { }); /// Dispatches [perWorker] requests to each worker and collects every result. - Future> run(String path, List perWorker) async { + Future> run( + String path, + List perWorker, { + String? rangeHeader, + }) async { final results = []; final done = {}; final completer = Completer(); @@ -77,6 +110,7 @@ void main() { url: 'http://${origin.address.host}:${origin.port}$path', requestCount: perWorker[id], firstSequence: sequence, + rangeHeader: rangeHeader, ), ); sequence += perWorker[id]; @@ -127,6 +161,22 @@ void main() { expect(results.single.statusCode, HttpStatus.notFound); }); + test('a range header yields a verified partial response', () async { + final results = await run( + '/payload.bin', + [2, 1], + rangeHeader: 'bytes=1024-5119', + ); + + expect(results, hasLength(3)); + for (final result in results) { + expect(result.statusCode, HttpStatus.partialContent); + expect(result.outcome, RequestOutcome.success); + expect(result.bytesReceived, 4096); + expect(result.contentLength, 4096); + } + }); + test('the pool reuses its isolates across jobs', () async { final first = await run('/payload.bin', [1, 1]); final second = await run('/payload.bin', [1, 1]); From c451600c3a7dbd0e538e619a345354b36dba4a5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:46:26 +0000 Subject: [PATCH 4/5] Add sequential window mode for partial requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the implicit full-vs-partial distinction with an explicit "Request range" segmented control offering three modes: full response (no Range header), fixed range (every request asks for the slider selection), and sequential windows (the selection is divided between the requests). In sequential mode the window is sized from the total request count — ceil(range / requests) — so the run walks the selected region exactly once, one request per window, each starting where the last ended. The final window slides back to end on the last byte, keeping every request the same size when the division is uneven. Windows are resolved from each request's global sequence number, so the assignment holds across workers as well as within one. Both modes are carried to the workers as a RangePlan on the job command; the worker resolves its window per request, so no per-request range list crosses the port. Partial modes stay disabled until the source length has been probed, and the panel previews exactly what will be requested — window count, window size and the byte span — updating as the request count is edited. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JvD7reUpdgiJeHaJz2XSE --- benchmarker/README.md | 32 +++-- .../lib/src/benchmark/benchmark_config.dart | 115 ++++++++++++++++-- .../src/benchmark/benchmark_controller.dart | 22 ++-- .../lib/src/benchmark/benchmark_worker.dart | 5 +- .../lib/src/benchmark/worker_protocol.dart | 12 +- benchmarker/lib/src/ui/benchmark_form.dart | 47 +++++-- .../lib/src/ui/widgets/config_panel.dart | 86 ++++++++++--- benchmarker/test/benchmark_config_test.dart | 99 ++++++++++++++- .../test/benchmark_controller_test.dart | 62 ++++++++-- benchmarker/test/benchmark_form_test.dart | 93 ++++++++++++-- benchmarker/test/config_panel_test.dart | 115 ++++++++++++------ benchmarker/test/worker_pool_test.dart | 48 ++++++-- 12 files changed, 608 insertions(+), 128 deletions(-) diff --git a/benchmarker/README.md b/benchmarker/README.md index aac98d6..73981b6 100644 --- a/benchmarker/README.md +++ b/benchmarker/README.md @@ -29,21 +29,33 @@ respawned when the concurrency or the client implementation changes. | Source URL | The remote URL under test. | | Concurrency | Number of worker isolates. | | Total requests | Requests issued in total, divided evenly between the workers (the remainder goes to the lowest-numbered workers). | -| Byte range | Portion of the source each request asks for, via a range slider. | +| Request range | Which bytes each request asks for: a mode plus a slider bounding the region. | -### Partial responses +### Request range -The range slider needs the source's size to express a selection in bytes, so -**Fetch length** probes the URL first — a `HEAD`, falling back to a one-byte -range request, which also reveals whether the server honors `Range` at all. The -probed length is dropped as soon as the URL is edited, so a stale size can never -be applied to a different source. +| Mode | Behavior | +| --- | --- | +| **Full response** | No `Range` header; every request downloads the whole source. | +| **Fixed range** | Every request asks for the same slider selection. | +| **Sequential windows** | The selection is divided between the requests: request *n* asks for the *n*-th window, each the same size, one starting where the last ended. | + +Sequential windows are sized by the total request count — `window = ⌈range ÷ +requests⌉` — so the run walks the selected region exactly once. The final window +slides back to end on the last byte, which keeps every request the same size at +the cost of a small overlap when the division isn't even. Windows are assigned +by each request's global sequence number, so workers cover consecutive blocks of +the region concurrently. + +Both partial modes need the source's size to express a selection in bytes, so +they stay disabled until **Fetch length** probes the URL — a `HEAD`, falling +back to a one-byte range request, which also reveals whether the server honors +`Range` at all. The probed length is dropped as soon as the URL is edited, so a +stale size can never be applied to a different source. Each request then carries `Range: bytes=-`, and the response's byte count is verified against its `Content-Length` exactly as a full response is. A -selection covering the whole source sends no `Range` header, and a run whose -range requests come back as anything other than `206 Partial Content` logs a -warning once — the server is likely ignoring the range. +run whose range requests come back as anything other than `206 Partial Content` +logs a warning once — the server is likely ignoring the range. ### Run types diff --git a/benchmarker/lib/src/benchmark/benchmark_config.dart b/benchmarker/lib/src/benchmark/benchmark_config.dart index dcd3b7b..a79d47c 100644 --- a/benchmarker/lib/src/benchmark/benchmark_config.dart +++ b/benchmarker/lib/src/benchmark/benchmark_config.dart @@ -49,8 +49,8 @@ class ByteRange { String get header => 'bytes=$start-$end'; /// Builds a range from two fractions of [contentLength], as produced by the - /// range slider. Returns null when the selection covers the whole source — - /// no `Range` header is sent then — or when it is empty. + /// range slider. Returns null when the content length is unknown or the + /// selection is empty. static ByteRange? fromFractions( double startFraction, double endFraction, @@ -58,9 +58,6 @@ class ByteRange { ) { final bounds = resolveBounds(startFraction, endFraction, contentLength); if (bounds == null) return null; - if (bounds.start <= 0 && bounds.endExclusive >= contentLength) { - return null; // Whole body. - } if (bounds.endExclusive <= bounds.start) return null; // Empty selection. return ByteRange(bounds.start, bounds.endExclusive - 1); } @@ -100,6 +97,108 @@ class ByteRange { String toString() => 'bytes $start-$end'; } +/// How each request's `Range` header is chosen. +enum RangeMode { + /// No `Range` header: every request asks for the whole body. + full( + 'Full response', + 'Every request downloads the entire source.', + ), + + /// Every request asks for the same selected range. + fixed( + 'Fixed range', + 'Every request asks for the same byte range.', + ), + + /// Each request asks for the next window of the selected range. + sequential( + 'Sequential windows', + 'The selected range is divided between the requests: each one asks for the ' + 'next window, the same size as the last.', + ); + + const RangeMode(this.label, this.description); + + final String label; + final String description; + + /// Whether this mode needs the source's content length to be known. + bool get needsContentLength => this != RangeMode.full; +} + +/// The `Range` each request in a run asks for. +/// +/// A plan covers both [RangeMode.fixed] — where [windowSize] spans the whole +/// range, so every request resolves to the same window — and +/// [RangeMode.sequential], where consecutive requests advance by [windowSize]. +class RangePlan { + const RangePlan({ + required this.start, + required this.end, + required this.windowSize, + }) : assert(start >= 0), + assert(end >= start), + assert(windowSize >= 1); + + /// First byte of the region requests are taken from, inclusive. + final int start; + + /// Last byte of the region, inclusive. + final int end; + + /// Bytes each individual request asks for. + final int windowSize; + + /// Size of the region the windows are taken from. + int get length => end - start + 1; + + /// Whether consecutive requests ask for different windows. + bool get isSequential => windowSize < length; + + /// Highest start offset that still leaves a full window before [end]. + int get _maxWindowStart => end - windowSize + 1; + + /// The window the request with this global [sequence] number asks for. + /// + /// Windows tile the region back to back. The final window slides back so it + /// ends exactly on [end], which keeps every request the same size at the cost + /// of a small overlap with the window before it. Sequences past the end of + /// the region resolve to that final window. + ByteRange windowFor(int sequence) { + final offset = start + sequence * windowSize; + final windowStart = offset > _maxWindowStart ? _maxWindowStart : offset; + return ByteRange(windowStart, windowStart + windowSize - 1); + } + + /// Builds a plan that divides [range] between [requestCount] requests. + /// + /// The window is rounded up so the windows always reach [ByteRange.end]; the + /// last one slides back to stay the same size as the rest. + static RangePlan sequential(ByteRange range, int requestCount) { + assert(requestCount >= 1); + final windowSize = + (range.length / requestCount).ceil().clamp(1, range.length); + return RangePlan( + start: range.start, + end: range.end, + windowSize: windowSize, + ); + } + + /// Builds a plan where every request asks for [range]. + static RangePlan fixed(ByteRange range) => RangePlan( + start: range.start, + end: range.end, + windowSize: range.length, + ); + + @override + String toString() => + 'RangePlan(bytes $start-$end, window $windowSize, ' + 'sequential: $isSequential)'; +} + /// A single benchmark run's inputs. class BenchmarkConfig { const BenchmarkConfig({ @@ -108,7 +207,7 @@ class BenchmarkConfig { required this.totalRequests, required this.type, required this.clientOption, - this.range, + this.rangePlan, }); /// The remote URL under test. @@ -125,8 +224,8 @@ class BenchmarkConfig { /// Which [HttpClientBuilder] the workers use. final HttpClientOption clientOption; - /// Byte range each request asks for, or null to request the full response. - final ByteRange? range; + /// Which bytes each request asks for, or null to request full responses. + final RangePlan? rangePlan; /// Splits [totalRequests] across [concurrency] workers as evenly as possible. /// diff --git a/benchmarker/lib/src/benchmark/benchmark_controller.dart b/benchmarker/lib/src/benchmark/benchmark_controller.dart index 1797b98..b90c8a3 100644 --- a/benchmarker/lib/src/benchmark/benchmark_controller.dart +++ b/benchmarker/lib/src/benchmark/benchmark_controller.dart @@ -106,11 +106,19 @@ class BenchmarkController extends ChangeNotifier { '${config.totalRequests} requests · ${config.concurrency} workers · ' '${config.clientOption.label}', ); - if (config.range case final range?) { - _log( - 'Partial responses: Range ${range.header} ' - '(${formatBytes(range.length)} per request).', - ); + if (config.rangePlan case final plan?) { + if (plan.isSequential) { + _log( + 'Sequential windows: ${config.totalRequests} × ' + '${formatBytes(plan.windowSize)} across bytes ' + '${plan.start}-${plan.end}; each request asks for the next window.', + ); + } else { + _log( + 'Partial responses: Range bytes=${plan.start}-${plan.end} ' + '(${formatBytes(plan.length)} per request).', + ); + } } _setPhase(BenchmarkPhase.preparing); @@ -275,7 +283,7 @@ class BenchmarkController extends ChangeNotifier { url: target.toString(), requestCount: count, firstSequence: sequence, - rangeHeader: config.range?.header, + rangePlan: config.rangePlan, ), ); sequence += count; @@ -376,7 +384,7 @@ class BenchmarkController extends ChangeNotifier { /// Logs problems without flooding the log: the first occurrence of each /// distinct problem is logged, then only at power-of-ten milestones. void _noteResult(RequestResult result) { - if (_config?.range != null && + if (_config?.rangePlan != null && !_warnedRangeIgnored && result.statusCode != null && result.statusCode != HttpStatus.partialContent) { diff --git a/benchmarker/lib/src/benchmark/benchmark_worker.dart b/benchmarker/lib/src/benchmark/benchmark_worker.dart index afe2bd0..dce6ef0 100644 --- a/benchmarker/lib/src/benchmark/benchmark_worker.dart +++ b/benchmarker/lib/src/benchmark/benchmark_worker.dart @@ -99,10 +99,11 @@ class _BenchmarkWorker { try { for (var i = 0; i < job.requestCount; i++) { if (_cancelRequested) break; + final sequence = job.firstSequence + i; final result = await _executeRequest( uri, - job.firstSequence + i, - job.rangeHeader, + sequence, + job.rangePlan?.windowFor(sequence).header, ); if (result == null) break; // Abandoned mid-response by a cancel. _pending.add(result); diff --git a/benchmarker/lib/src/benchmark/worker_protocol.dart b/benchmarker/lib/src/benchmark/worker_protocol.dart index 5a6397f..b968b37 100644 --- a/benchmarker/lib/src/benchmark/worker_protocol.dart +++ b/benchmarker/lib/src/benchmark/worker_protocol.dart @@ -1,5 +1,6 @@ import 'dart:isolate'; +import 'benchmark_config.dart'; import 'http_client_builder.dart'; /// The spawn message handed to a worker isolate. @@ -30,7 +31,7 @@ class RunJobCommand extends WorkerCommand { required this.url, required this.requestCount, required this.firstSequence, - this.rangeHeader, + this.rangePlan, }); final int jobId; @@ -43,9 +44,12 @@ class RunJobCommand extends WorkerCommand { /// Global index of this job's first request, used to label results. final int firstSequence; - /// Value for the `Range` header, e.g. `bytes=0-1023`. Null requests the full - /// response. - final String? rangeHeader; + /// Which bytes each request asks for. Null requests full responses. + /// + /// The worker resolves the window from the request's global sequence number, + /// so consecutive requests — across workers as well as within one — ask for + /// consecutive windows. + final RangePlan? rangePlan; } /// Stop the active job early. The worker still reports a [JobDoneEvent]. diff --git a/benchmarker/lib/src/ui/benchmark_form.dart b/benchmarker/lib/src/ui/benchmark_form.dart index f42f442..c0b4977 100644 --- a/benchmarker/lib/src/ui/benchmark_form.dart +++ b/benchmarker/lib/src/ui/benchmark_form.dart @@ -21,8 +21,10 @@ class BenchmarkForm extends ChangeNotifier { int totalRequests = 40, BenchmarkType type = BenchmarkType.preCached, HttpClientOption? clientOption, + RangeMode rangeMode = RangeMode.full, this.probe = probeSource, - }) : urlController = TextEditingController(text: url), + }) : _rangeMode = rangeMode, + urlController = TextEditingController(text: url), concurrencyController = TextEditingController(text: concurrency.toString()), requestsController = @@ -46,6 +48,7 @@ class BenchmarkForm extends ChangeNotifier { HttpClientOption _clientOption; String? _error; + RangeMode _rangeMode; RangeValues _rangeFraction = const RangeValues(0, 1); ProbeStatus _probeStatus = ProbeStatus.idle; String? _probeError; @@ -125,6 +128,7 @@ class BenchmarkForm extends ChangeNotifier { _probeStatus = ProbeStatus.failed; _probeError = '$e'; } + if (!canSelectRange) _rangeMode = RangeMode.full; notifyListeners(); } @@ -138,13 +142,27 @@ class BenchmarkForm extends ChangeNotifier { _probeStatus = ProbeStatus.idle; _probeError = null; _rangeFraction = const RangeValues(0, 1); + _rangeMode = RangeMode.full; // Range modes need a known content length. notifyListeners(); } + /// Total requests currently entered, if the field holds a number. + int? get plannedRequestCount => int.tryParse(requestsController.text.trim()); + // --------------------------------------------------------------------------- // Range selection // --------------------------------------------------------------------------- + /// How each request's `Range` header is chosen. + RangeMode get rangeMode => _rangeMode; + + set rangeMode(RangeMode value) { + if (_rangeMode == value) return; + if (value.needsContentLength && !canSelectRange) return; + _rangeMode = value; + notifyListeners(); + } + /// Selected portion of the source, as fractions from 0 to 1. RangeValues get rangeFraction => _rangeFraction; @@ -156,7 +174,8 @@ class BenchmarkForm extends ChangeNotifier { notifyListeners(); } - /// The byte range requests should ask for, or null for the full response. + /// The region of the source requests are taken from, or null when the source + /// length is unknown or the selection is empty. ByteRange? selectedRange() { final length = _contentLength; if (length == null || length <= 0) return null; @@ -178,8 +197,23 @@ class BenchmarkForm extends ChangeNotifier { ); } - /// Whether the whole source is requested, so no `Range` header is sent. - bool get isFullRange => !isEmptySelection && selectedRange() == null; + /// Whether the selection spans the whole source. + bool get isFullRange => selectedRange()?.length == _contentLength; + + /// The plan the current inputs describe, or null when full responses are + /// requested or no valid range is selected. + /// + /// [requestCount] sets how finely [RangeMode.sequential] divides the range. + RangePlan? buildRangePlan(int requestCount) { + if (_rangeMode == RangeMode.full || requestCount < 1) return null; + final range = selectedRange(); + if (range == null) return null; + return switch (_rangeMode) { + RangeMode.full => null, + RangeMode.fixed => RangePlan.fixed(range), + RangeMode.sequential => RangePlan.sequential(range, requestCount), + }; + } // --------------------------------------------------------------------------- // Config @@ -197,8 +231,7 @@ class BenchmarkForm extends ChangeNotifier { concurrency: concurrency, totalRequests: totalRequests, ); - final range = error == null ? selectedRange() : null; - if (error == null && isEmptySelection) { + if (error == null && _rangeMode.needsContentLength && isEmptySelection) { error = 'The selected range is empty.'; } @@ -214,7 +247,7 @@ class BenchmarkForm extends ChangeNotifier { totalRequests: totalRequests!, type: _type, clientOption: _clientOption, - range: range, + rangePlan: buildRangePlan(totalRequests), ); } diff --git a/benchmarker/lib/src/ui/widgets/config_panel.dart b/benchmarker/lib/src/ui/widgets/config_panel.dart index c02b1b9..097a774 100644 --- a/benchmarker/lib/src/ui/widgets/config_panel.dart +++ b/benchmarker/lib/src/ui/widgets/config_panel.dart @@ -195,9 +195,19 @@ class _RangeSelector extends StatelessWidget { @override Widget build(BuildContext context) { + // The sequential window size follows the total request count, so this + // section also rebuilds when that field changes. + return ListenableBuilder( + listenable: form.requestsController, + builder: (context, _) => _build(context), + ); + } + + Widget _build(BuildContext context) { final theme = Theme.of(context); final contentLength = form.contentLength; - final enabled = !isBusy && form.canSelectRange; + final enabled = + !isBusy && form.canSelectRange && form.rangeMode.needsContentLength; final bounds = contentLength == null ? null : ByteRange.resolveBounds( @@ -206,21 +216,6 @@ class _RangeSelector extends StatelessWidget { contentLength, ); - final String detail; - if (contentLength == null) { - detail = 'Fetch the source length to request partial responses.'; - } else if (form.isEmptySelection) { - detail = 'Empty selection — widen the range.'; - } else if (form.isFullRange) { - detail = 'Full response · ${formatBytes(contentLength)} ' - '($contentLength bytes), no Range header'; - } else { - final range = form.selectedRange()!; - detail = 'Range ${range.header} · ${formatBytes(range.length)} ' - '(${range.length} bytes, ' - '${formatPercent(range.length / contentLength)} of source)'; - } - return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -228,7 +223,7 @@ class _RangeSelector extends StatelessWidget { children: [ Expanded( child: Text( - 'Requested byte range', + 'Request range', style: theme.textTheme.labelLarge, ), ), @@ -251,6 +246,28 @@ class _RangeSelector extends StatelessWidget { ), ], ), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + segments: [ + for (final mode in RangeMode.values) + ButtonSegment( + value: mode, + label: Text(mode.label), + // Byte ranges cannot be expressed until the size is known. + enabled: !mode.needsContentLength || form.canSelectRange, + ), + ], + selected: {form.rangeMode}, + showSelectedIcon: false, + onSelectionChanged: + isBusy ? null : (selection) => form.rangeMode = selection.first, + ), + ), + ), RangeSlider( values: form.rangeFraction, divisions: 200, @@ -265,9 +282,9 @@ class _RangeSelector extends StatelessWidget { onChanged: enabled ? (values) => form.rangeFraction = values : null, ), Text( - detail, + _detail(contentLength), style: theme.textTheme.bodySmall?.copyWith( - color: form.isEmptySelection + color: form.rangeMode.needsContentLength && form.isEmptySelection ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant, ), @@ -296,6 +313,37 @@ class _RangeSelector extends StatelessWidget { ); } + /// One line describing exactly what the workers will request. + String _detail(int? contentLength) { + if (form.rangeMode == RangeMode.full) { + final size = + contentLength == null ? '' : ' · ${formatBytes(contentLength)}'; + return 'Full response$size, no Range header'; + } + if (contentLength == null) { + return 'Fetch the source length to request partial responses.'; + } + if (form.isEmptySelection) { + return 'Empty selection — widen the range.'; + } + + final range = form.selectedRange()!; + final share = formatPercent(range.length / contentLength); + if (form.rangeMode == RangeMode.fixed) { + return 'Every request: Range ${range.header} · ' + '${formatBytes(range.length)} ($share of source)'; + } + + final requestCount = form.plannedRequestCount ?? 0; + final plan = form.buildRangePlan(requestCount); + if (plan == null) { + return 'Enter a total request count to size the windows.'; + } + return '$requestCount windows × ${formatBytes(plan.windowSize)} ' + '(${plan.windowSize} bytes) across bytes ${range.start}-${range.end} ' + '· $share of source'; + } + static String _thumbLabel(int? offset, int? contentLength, double fraction) { if (offset == null || contentLength == null) return formatPercent(fraction); return formatBytes(offset); diff --git a/benchmarker/test/benchmark_config_test.dart b/benchmarker/test/benchmark_config_test.dart index e1f949f..2e87cc7 100644 --- a/benchmarker/test/benchmark_config_test.dart +++ b/benchmarker/test/benchmark_config_test.dart @@ -50,10 +50,10 @@ void main() { expect(range.header, 'bytes=250-499'); }); - test('returns null for a full selection so no Range header is sent', () { - expect(ByteRange.fromFractions(0, 1, 1000), isNull); + test('spans the whole source for a full selection', () { + expect(ByteRange.fromFractions(0, 1, 1000), const ByteRange(0, 999)); // Rounds up to the last byte, which is still the whole body. - expect(ByteRange.fromFractions(0, 0.9999, 1000), isNull); + expect(ByteRange.fromFractions(0, 0.9999, 1000), const ByteRange(0, 999)); }); test('returns null for an empty selection', () { @@ -76,6 +76,99 @@ void main() { }); }); + group('RangePlan', () { + test('a fixed plan gives every request the same window', () { + final plan = RangePlan.fixed(const ByteRange(100, 199)); + + expect(plan.windowSize, 100); + expect(plan.isSequential, isFalse); + for (var sequence = 0; sequence < 5; sequence++) { + expect(plan.windowFor(sequence), const ByteRange(100, 199)); + } + }); + + test('a sequential plan tiles the range back to back', () { + final plan = RangePlan.sequential(const ByteRange(0, 999), 4); + + expect(plan.windowSize, 250); + expect(plan.isSequential, isTrue); + expect(plan.windowFor(0), const ByteRange(0, 249)); + expect(plan.windowFor(1), const ByteRange(250, 499)); + expect(plan.windowFor(2), const ByteRange(500, 749)); + expect(plan.windowFor(3), const ByteRange(750, 999)); + }); + + test('windows start where the previous one ended', () { + const requestCount = 7; + final plan = RangePlan.sequential(const ByteRange(4096, 20479), requestCount); + + // Every window but the last starts directly after its predecessor; the + // last slides back to end on the final byte, so it may overlap. + for (var sequence = 1; sequence < requestCount - 1; sequence++) { + final previous = plan.windowFor(sequence - 1); + final current = plan.windowFor(sequence); + expect( + current.start, + previous.end + 1, + reason: 'window $sequence should follow window ${sequence - 1}', + ); + } + + final last = plan.windowFor(requestCount - 1); + final secondToLast = plan.windowFor(requestCount - 2); + expect(last.end, 20479); + expect(last.length, plan.windowSize); + expect(last.start, lessThanOrEqualTo(secondToLast.end + 1)); + expect(last.start, greaterThan(secondToLast.start)); + }); + + test('every window is the same size, the last sliding back to the end', () { + // 1000 bytes over 3 requests does not divide evenly. + final plan = RangePlan.sequential(const ByteRange(0, 999), 3); + + expect(plan.windowSize, 334); + expect(plan.windowFor(0), const ByteRange(0, 333)); + expect(plan.windowFor(1), const ByteRange(334, 667)); + // Slid back so it stays 334 bytes and still ends on the last byte. + expect(plan.windowFor(2), const ByteRange(666, 999)); + for (var sequence = 0; sequence < 3; sequence++) { + expect(plan.windowFor(sequence).length, plan.windowSize); + } + }); + + test('the windows cover the whole selected range', () { + for (final requestCount in [1, 2, 3, 7, 16, 100]) { + final plan = RangePlan.sequential(const ByteRange(500, 1499), requestCount); + expect(plan.windowFor(0).start, 500); + expect(plan.windowFor(requestCount - 1).end, 1499); + } + }); + + test('a single request covers the entire range', () { + final plan = RangePlan.sequential(const ByteRange(0, 999), 1); + + expect(plan.windowSize, 1000); + expect(plan.isSequential, isFalse); + expect(plan.windowFor(0), const ByteRange(0, 999)); + }); + + test('sequences past the end resolve to the final window', () { + final plan = RangePlan.sequential(const ByteRange(0, 999), 4); + + expect(plan.windowFor(4), const ByteRange(750, 999)); + expect(plan.windowFor(99), const ByteRange(750, 999)); + }); + + test('more requests than bytes still produce valid windows', () { + final plan = RangePlan.sequential(const ByteRange(0, 9), 40); + + expect(plan.windowSize, 1); + expect(plan.windowFor(0), const ByteRange(0, 0)); + expect(plan.windowFor(9), const ByteRange(9, 9)); + expect(plan.windowFor(39), const ByteRange(9, 9)); + }); + }); + group('validate', () { test('accepts a well-formed config', () { expect( diff --git a/benchmarker/test/benchmark_controller_test.dart b/benchmarker/test/benchmark_controller_test.dart index a214d8b..59165b5 100644 --- a/benchmarker/test/benchmark_controller_test.dart +++ b/benchmarker/test/benchmark_controller_test.dart @@ -29,18 +29,19 @@ void main() { late BenchmarkController controller; late Uri sourceUrl; var originRequests = 0; + final receivedRanges = []; setUp(() async { originRequests = 0; + receivedRanges.clear(); origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); unawaited(() async { await for (final request in origin) { originRequests++; request.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); - final range = _parseRange( - request.headers.value(HttpHeaders.rangeHeader), - payload.length, - ); + final rangeHeader = request.headers.value(HttpHeaders.rangeHeader); + if (rangeHeader != null) receivedRanges.add(rangeHeader); + final range = _parseRange(rangeHeader, payload.length); if (range == null) { request.response.headers.contentLength = payload.length; request.response.add(payload); @@ -81,7 +82,7 @@ void main() { BenchmarkType type, { int workers = 2, int total = 4, - ByteRange? range, + RangePlan? rangePlan, }) { return BenchmarkConfig( sourceUrl: sourceUrl, @@ -89,7 +90,7 @@ void main() { totalRequests: total, type: type, clientOption: kHttpClientOptions.first, - range: range, + rangePlan: rangePlan, ); } @@ -155,7 +156,9 @@ void main() { test('direct run requests only the selected byte range', () async { const range = ByteRange(1024, 5119); - await controller.start(configFor(BenchmarkType.direct, range: range)); + await controller.start( + configFor(BenchmarkType.direct, rangePlan: RangePlan.fixed(range)), + ); expect(controller.phase, BenchmarkPhase.finished); final stats = controller.stats!; @@ -173,7 +176,9 @@ void main() { test('pre-cached run serves the selected byte range from the cache', () async { const range = ByteRange(4096, 8191); - await controller.start(configFor(BenchmarkType.preCached, range: range)); + await controller.start( + configFor(BenchmarkType.preCached, rangePlan: RangePlan.fixed(range)), + ); expect(controller.phase, BenchmarkPhase.finished); expect(controller.cacheState!.isComplete, isTrue); @@ -192,6 +197,47 @@ void main() { ); }); + test('sequential windows walk the range once across all workers', () async { + // The whole payload divided between 8 requests on 2 workers. + final plan = RangePlan.sequential(ByteRange(0, payload.length - 1), 8); + await controller.start( + configFor(BenchmarkType.direct, total: 8, rangePlan: plan), + ); + + expect(controller.phase, BenchmarkPhase.finished); + final stats = controller.stats!; + expect(stats.completed, 8); + expect(stats.succeeded, 8); + expect(stats.errorCount, 0); + // Every window is the same size and together they cover the payload once. + expect(stats.totalBytes, payload.length); + expect(stats.avgBytesPerRequest, plan.windowSize.toDouble()); + expect(receivedRanges..sort(), [ + for (var sequence = 0; sequence < 8; sequence++) + plan.windowFor(sequence).header, + ]..sort()); + expect( + controller.logs.map((entry) => entry.message), + contains(contains('Sequential windows: 8 ×')), + ); + }); + + test('sequential windows are served from the cache', () async { + final plan = RangePlan.sequential(ByteRange(0, payload.length - 1), 4); + await controller.start( + configFor(BenchmarkType.preCached, total: 4, rangePlan: plan), + ); + + expect(controller.phase, BenchmarkPhase.finished); + final stats = controller.stats!; + expect(stats.completed, 4); + expect(stats.succeeded, 4); + expect(stats.errorCount, 0); + expect(stats.totalBytes, payload.length); + // Only the pre-cache download reached the origin. + expect(originRequests, 1); + }); + test('the worker pool is reused between runs with the same settings', () async { await controller.start(configFor(BenchmarkType.direct, total: 2)); diff --git a/benchmarker/test/benchmark_form_test.dart b/benchmarker/test/benchmark_form_test.dart index d6c177d..ea8d560 100644 --- a/benchmarker/test/benchmark_form_test.dart +++ b/benchmarker/test/benchmark_form_test.dart @@ -1,3 +1,4 @@ +import 'package:benchmarker/src/benchmark/benchmark_config.dart'; import 'package:benchmarker/src/benchmark/source_probe.dart'; import 'package:benchmarker/src/ui/benchmark_form.dart'; import 'package:flutter/material.dart'; @@ -7,9 +8,11 @@ BenchmarkForm _form({ int? contentLength = 1000, bool acceptsRanges = true, Object? failWith, + int totalRequests = 40, }) { return BenchmarkForm( url: 'https://example.com/file.bin', + totalRequests: totalRequests, probe: (uri) async { if (failWith != null) throw failWith; return SourceInfo( @@ -21,13 +24,17 @@ BenchmarkForm _form({ } void main() { - test('a range cannot be selected before the source length is known', () { + test('range modes are unavailable before the source length is known', () { final form = _form(); addTearDown(form.dispose); + expect(form.rangeMode, RangeMode.full); expect(form.canSelectRange, isFalse); - expect(form.selectedRange(), isNull); - expect(form.buildConfig()!.range, isNull); + + form.rangeMode = RangeMode.sequential; + + expect(form.rangeMode, RangeMode.full, reason: 'refused without a length'); + expect(form.buildConfig()!.rangePlan, isNull); }); test('fetching the length enables ranges and reports the source size', @@ -41,23 +48,68 @@ void main() { expect(form.contentLength, 2048); expect(form.acceptsRanges, isTrue); expect(form.canSelectRange, isTrue); - // The whole source is selected, so no Range header is sent. expect(form.isFullRange, isTrue); - expect(form.buildConfig()!.range, isNull); + // Still in full-response mode, so no Range header is sent. + expect(form.rangeMode, RangeMode.full); + expect(form.buildConfig()!.rangePlan, isNull); }); - test('a partial selection becomes the config range', () async { + test('fixed mode sends the selected range with every request', () async { final form = _form(contentLength: 1000); addTearDown(form.dispose); await form.fetchSourceLength(); + form.rangeMode = RangeMode.fixed; form.rangeFraction = const RangeValues(0.25, 0.75); - final range = form.buildConfig()!.range!; - expect(range.start, 250); - expect(range.end, 749); - expect(range.length, 500); - expect(range.header, 'bytes=250-749'); + final plan = form.buildConfig()!.rangePlan!; + expect(plan.isSequential, isFalse); + expect(plan.windowSize, 500); + expect(plan.windowFor(0), const ByteRange(250, 749)); + expect(plan.windowFor(9), const ByteRange(250, 749)); + }); + + test('sequential mode divides the range between the requests', () async { + final form = _form(contentLength: 1000, totalRequests: 4); + addTearDown(form.dispose); + await form.fetchSourceLength(); + + form.rangeMode = RangeMode.sequential; + + final plan = form.buildConfig()!.rangePlan!; + expect(plan.isSequential, isTrue); + expect(plan.windowSize, 250); + expect(plan.windowFor(0), const ByteRange(0, 249)); + expect(plan.windowFor(3), const ByteRange(750, 999)); + }); + + test('sequential windows follow the request count', () async { + final form = _form(contentLength: 1000, totalRequests: 4); + addTearDown(form.dispose); + await form.fetchSourceLength(); + form.rangeMode = RangeMode.sequential; + + expect(form.buildRangePlan(4)!.windowSize, 250); + expect(form.buildRangePlan(10)!.windowSize, 100); + + form.requestsController.text = '10'; + expect(form.buildConfig()!.rangePlan!.windowSize, 100); + }); + + test('sequential windows stay inside the selected sub-range', () async { + final form = _form(contentLength: 1000, totalRequests: 4); + addTearDown(form.dispose); + await form.fetchSourceLength(); + + form.rangeMode = RangeMode.sequential; + form.rangeFraction = const RangeValues(0.5, 1); + + final plan = form.buildConfig()!.rangePlan!; + expect(plan.start, 500); + expect(plan.end, 999); + expect(plan.windowSize, 125); + expect(plan.windowFor(0), const ByteRange(500, 624)); + expect(plan.windowFor(3), const ByteRange(875, 999)); }); test('an empty selection is rejected with an error', () async { @@ -65,6 +117,7 @@ void main() { addTearDown(form.dispose); await form.fetchSourceLength(); + form.rangeMode = RangeMode.fixed; form.rangeFraction = const RangeValues(0.5, 0.5); expect(form.isEmptySelection, isTrue); @@ -72,10 +125,22 @@ void main() { expect(form.error, 'The selected range is empty.'); }); - test('changing the URL drops the probed length', () async { + test('an empty selection is ignored in full-response mode', () async { + final form = _form(contentLength: 1000); + addTearDown(form.dispose); + await form.fetchSourceLength(); + + form.rangeFraction = const RangeValues(0.5, 0.5); + + expect(form.buildConfig()!.rangePlan, isNull); + expect(form.error, isNull); + }); + + test('changing the URL drops the probed length and the range mode', () async { final form = _form(contentLength: 1000); addTearDown(form.dispose); await form.fetchSourceLength(); + form.rangeMode = RangeMode.sequential; form.rangeFraction = const RangeValues(0.25, 0.75); form.urlController.text = 'https://example.com/other.bin'; @@ -83,8 +148,9 @@ void main() { expect(form.contentLength, isNull); expect(form.canSelectRange, isFalse); expect(form.probeStatus, ProbeStatus.idle); + expect(form.rangeMode, RangeMode.full); expect(form.rangeFraction, const RangeValues(0, 1)); - expect(form.buildConfig()!.range, isNull); + expect(form.buildConfig()!.rangePlan, isNull); }); test('a failed probe is reported and leaves ranges disabled', () async { @@ -96,6 +162,7 @@ void main() { expect(form.probeStatus, ProbeStatus.failed); expect(form.probeError, contains('offline')); expect(form.canSelectRange, isFalse); + expect(form.rangeMode, RangeMode.full); }); test('a source without a Content-Length is reported', () async { diff --git a/benchmarker/test/config_panel_test.dart b/benchmarker/test/config_panel_test.dart index 4f8fcad..ca2527e 100644 --- a/benchmarker/test/config_panel_test.dart +++ b/benchmarker/test/config_panel_test.dart @@ -6,15 +6,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('the range slider drives the requested byte range', - (tester) async { - final form = BenchmarkForm( + late BenchmarkForm form; + BenchmarkConfig? started; + var runs = 0; + + Future pumpPanel(WidgetTester tester, {int totalRequests = 4}) async { + started = null; + runs = 0; + form = BenchmarkForm( url: 'https://example.com/file.bin', + totalRequests: totalRequests, probe: (uri) async => const SourceInfo(contentLength: 1000, acceptsRanges: true), ); addTearDown(form.dispose); - BenchmarkConfig? started; await tester.pumpWidget( MaterialApp( @@ -24,67 +29,103 @@ void main() { form: form, isBusy: false, canCancel: false, - onRun: (config) => started = config, + onRun: (config) { + started = config; + runs++; + }, onCancel: () {}, ), ), ), ), ); + } + + testWidgets('range modes stay disabled until the length is fetched', + (tester) async { + await pumpPanel(tester); RangeSlider slider() => tester.widget(find.byType(RangeSlider)); + ButtonSegment segment(RangeMode mode) => tester + .widget>( + find.byType(SegmentedButton), + ) + .segments + .whereType>() + .firstWhere((segment) => segment.value == mode); - // Without a known source length the slider is disabled. expect(slider().onChanged, isNull); - expect( - find.text('Fetch the source length to request partial responses.'), - findsOneWidget, - ); + expect(segment(RangeMode.full).enabled, isTrue); + expect(segment(RangeMode.fixed).enabled, isFalse); + expect(segment(RangeMode.sequential).enabled, isFalse); + expect(find.textContaining('no Range header'), findsOneWidget); await tester.tap(find.text('Fetch length')); await tester.pumpAndSettle(); + expect(segment(RangeMode.fixed).enabled, isTrue); + expect(segment(RangeMode.sequential).enabled, isTrue); + // The slider only bites once a partial mode is selected. + expect(slider().onChanged, isNull); + + await tester.tap(find.text(RangeMode.fixed.label)); + await tester.pumpAndSettle(); expect(slider().onChanged, isNotNull); - expect(find.textContaining('Full response'), findsOneWidget); + }); + + testWidgets('fixed mode sends the slider selection with every request', + (tester) async { + await pumpPanel(tester); + await tester.tap(find.text('Fetch length')); + await tester.pumpAndSettle(); + await tester.tap(find.text(RangeMode.fixed.label)); + await tester.pumpAndSettle(); - // Selecting a sub-range shows it in bytes and sends it with the run. form.rangeFraction = const RangeValues(0.25, 0.75); await tester.pumpAndSettle(); - expect(find.textContaining('Range bytes=250-749'), findsOneWidget); + expect(find.textContaining('Every request: Range bytes=250-749'), + findsOneWidget); await tester.tap(find.text('Run benchmark')); await tester.pumpAndSettle(); - expect(started!.range, const ByteRange(250, 749)); + final plan = started!.rangePlan!; + expect(plan.isSequential, isFalse); + expect(plan.windowFor(0), const ByteRange(250, 749)); }); - testWidgets('an empty range selection blocks the run', (tester) async { - final form = BenchmarkForm( - url: 'https://example.com/file.bin', - probe: (uri) async => - const SourceInfo(contentLength: 1000, acceptsRanges: true), - ); - addTearDown(form.dispose); - var runs = 0; + testWidgets('sequential mode divides the range across the requests', + (tester) async { + await pumpPanel(tester, totalRequests: 4); + await tester.tap(find.text('Fetch length')); + await tester.pumpAndSettle(); + await tester.tap(find.text(RangeMode.sequential.label)); + await tester.pumpAndSettle(); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: ConfigPanel( - form: form, - isBusy: false, - canCancel: false, - onRun: (_) => runs++, - onCancel: () {}, - ), - ), - ), - ), - ); + expect(find.textContaining('4 windows × 250 B'), findsOneWidget); + + // The window size follows the total request count as it is edited. + await tester.enterText(find.byType(TextField).at(2), '10'); + await tester.pumpAndSettle(); + expect(find.textContaining('10 windows × 100 B'), findsOneWidget); + + await tester.tap(find.text('Run benchmark')); + await tester.pumpAndSettle(); + + final plan = started!.rangePlan!; + expect(plan.isSequential, isTrue); + expect(plan.windowSize, 100); + expect(plan.windowFor(0), const ByteRange(0, 99)); + expect(plan.windowFor(9), const ByteRange(900, 999)); + }); + testWidgets('an empty range selection blocks the run', (tester) async { + await pumpPanel(tester); await tester.tap(find.text('Fetch length')); await tester.pumpAndSettle(); + await tester.tap(find.text(RangeMode.fixed.label)); + await tester.pumpAndSettle(); + form.rangeFraction = const RangeValues(0.5, 0.5); await tester.pumpAndSettle(); diff --git a/benchmarker/test/worker_pool_test.dart b/benchmarker/test/worker_pool_test.dart index d10c9e1..da63191 100644 --- a/benchmarker/test/worker_pool_test.dart +++ b/benchmarker/test/worker_pool_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:benchmarker/src/benchmark/benchmark_config.dart'; import 'package:benchmarker/src/benchmark/http_client_builder.dart'; import 'package:benchmarker/src/benchmark/worker_pool.dart'; import 'package:benchmarker/src/benchmark/worker_protocol.dart'; @@ -9,10 +10,15 @@ import 'package:flutter_test/flutter_test.dart'; /// Serves a fixed payload with a correct `Content-Length`, honoring single /// byte ranges, plus endpoints for a chunked (length-less) response and an /// error status. -Future _startOrigin(List payload) async { +Future _startOrigin( + List payload, + List receivedRanges, +) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); unawaited(() async { await for (final request in server) { + final rangeHeader = request.headers.value(HttpHeaders.rangeHeader); + if (rangeHeader != null) receivedRanges.add(rangeHeader); switch (request.uri.path) { case '/chunked': request.response.headers.chunkedTransferEncoding = true; @@ -20,10 +26,7 @@ Future _startOrigin(List payload) async { case '/error': request.response.statusCode = HttpStatus.notFound; default: - final range = _parseRange( - request.headers.value(HttpHeaders.rangeHeader), - payload.length, - ); + final range = _parseRange(rangeHeader, payload.length); if (range == null) { request.response.headers.contentLength = payload.length; request.response.add(payload); @@ -59,11 +62,13 @@ Future _startOrigin(List payload) async { void main() { final payload = List.generate(64 * 1024, (index) => index % 256); + final receivedRanges = []; late HttpServer origin; late WorkerPool pool; setUp(() async { - origin = await _startOrigin(payload); + receivedRanges.clear(); + origin = await _startOrigin(payload, receivedRanges); pool = await WorkerPool.spawn( size: 2, clientOption: kHttpClientOptions.first, @@ -79,7 +84,7 @@ void main() { Future> run( String path, List perWorker, { - String? rangeHeader, + RangePlan? rangePlan, }) async { final results = []; final done = {}; @@ -110,7 +115,7 @@ void main() { url: 'http://${origin.address.host}:${origin.port}$path', requestCount: perWorker[id], firstSequence: sequence, - rangeHeader: rangeHeader, + rangePlan: rangePlan, ), ); sequence += perWorker[id]; @@ -161,11 +166,11 @@ void main() { expect(results.single.statusCode, HttpStatus.notFound); }); - test('a range header yields a verified partial response', () async { + test('a fixed range plan yields a verified partial response', () async { final results = await run( '/payload.bin', [2, 1], - rangeHeader: 'bytes=1024-5119', + rangePlan: RangePlan.fixed(const ByteRange(1024, 5119)), ); expect(results, hasLength(3)); @@ -175,6 +180,29 @@ void main() { expect(result.bytesReceived, 4096); expect(result.contentLength, 4096); } + expect(receivedRanges, everyElement('bytes=1024-5119')); + }); + + test('a sequential plan walks the range one window per request', () async { + // 8 KB across 4 requests: four back-to-back 2 KB windows. + final plan = RangePlan.sequential(const ByteRange(0, 8191), 4); + final results = await run('/payload.bin', [2, 2], rangePlan: plan); + + expect(plan.windowSize, 2048); + expect(results, hasLength(4)); + for (final result in results) { + expect(result.statusCode, HttpStatus.partialContent); + expect(result.outcome, RequestOutcome.success); + expect(result.bytesReceived, 2048); + } + // Each request asked for a distinct, contiguous window; the workers split + // the sequence between them. + expect(receivedRanges..sort(), [ + 'bytes=0-2047', + 'bytes=2048-4095', + 'bytes=4096-6143', + 'bytes=6144-8191', + ]); }); test('the pool reuses its isolates across jobs', () async { From da5cceb822fe3448fa37693279250de7c4e945bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 00:09:15 +0000 Subject: [PATCH 5/5] Add copy-to-clipboard for benchmark statistics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts a copy button at the top right of the statistics panel offering the run as plain text or as JSON. Both formats carry the run's identity alongside the numbers — source URL, target URL, cache type, request range, http client and concurrency — so a copied result is self-describing. The range description is now built in one place and shared between the reports and the run log, so the two can no longer drift. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JvD7reUpdgiJeHaJz2XSE --- benchmarker/README.md | 6 + .../src/benchmark/benchmark_controller.dart | 17 +- .../lib/src/benchmark/benchmark_report.dart | 171 +++++++++++++++++ benchmarker/lib/src/ui/benchmark_page.dart | 7 +- .../lib/src/ui/widgets/stats_panel.dart | 65 ++++++- .../test/benchmark_controller_test.dart | 2 +- benchmarker/test/benchmark_report_test.dart | 173 ++++++++++++++++++ benchmarker/test/stats_panel_test.dart | 125 +++++++++++++ 8 files changed, 551 insertions(+), 15 deletions(-) create mode 100644 benchmarker/lib/src/benchmark/benchmark_report.dart create mode 100644 benchmarker/test/benchmark_report_test.dart create mode 100644 benchmarker/test/stats_panel_test.dart diff --git a/benchmarker/README.md b/benchmarker/README.md index 73981b6..1ce9fcc 100644 --- a/benchmarker/README.md +++ b/benchmarker/README.md @@ -88,6 +88,11 @@ roughly four times a second: Every response's received byte count is checked against its `Content-Length`; a mismatch is reported as a problem rather than a success. +The copy button at the top right of the statistics panel puts the whole run on +the clipboard — source URL, target URL, cache type, request range, client and +concurrency alongside the numbers — as either an aligned plain-text summary or +JSON for feeding into other tooling. + For cache-server runs, the page also shows live download progress (`x / y bytes`, percentage) taken from `HttpCacheStream.cacheStateStream`, and errors emitted by that stream are written to the log panel along with the run's @@ -101,6 +106,7 @@ lib/ src/benchmark/ benchmark_config.dart inputs, run types, request distribution benchmark_controller.dart run orchestration and aggregation + benchmark_report.dart clipboard reports (text and JSON) benchmark_stats.dart timing/percentile accumulation benchmark_worker.dart worker isolate entry point http_client_builder.dart selectable http client implementations diff --git a/benchmarker/lib/src/benchmark/benchmark_controller.dart b/benchmarker/lib/src/benchmark/benchmark_controller.dart index b90c8a3..abe3dbd 100644 --- a/benchmarker/lib/src/benchmark/benchmark_controller.dart +++ b/benchmarker/lib/src/benchmark/benchmark_controller.dart @@ -7,6 +7,7 @@ import 'package:http_cache_stream/http_cache_stream.dart'; import '../util/formatting.dart'; import 'benchmark_config.dart'; import 'benchmark_log.dart'; +import 'benchmark_report.dart'; import 'benchmark_stats.dart'; import 'worker_pool.dart'; import 'worker_protocol.dart'; @@ -107,18 +108,10 @@ class BenchmarkController extends ChangeNotifier { '${config.clientOption.label}', ); if (config.rangePlan case final plan?) { - if (plan.isSequential) { - _log( - 'Sequential windows: ${config.totalRequests} × ' - '${formatBytes(plan.windowSize)} across bytes ' - '${plan.start}-${plan.end}; each request asks for the next window.', - ); - } else { - _log( - 'Partial responses: Range bytes=${plan.start}-${plan.end} ' - '(${formatBytes(plan.length)} per request).', - ); - } + _log( + '${describeRangePlan(config)}' + '${plan.isSequential ? '; each request asks for the next window.' : '.'}', + ); } _setPhase(BenchmarkPhase.preparing); diff --git a/benchmarker/lib/src/benchmark/benchmark_report.dart b/benchmarker/lib/src/benchmark/benchmark_report.dart new file mode 100644 index 0000000..70510c1 --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_report.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; + +import '../util/formatting.dart'; +import 'benchmark_config.dart'; +import 'benchmark_stats.dart'; + +/// One line describing what each request in [config] asks for. +String describeRangePlan(BenchmarkConfig config) { + final plan = config.rangePlan; + if (plan == null) return 'Full response (no Range header)'; + if (plan.isSequential) { + return 'Sequential windows: ${config.totalRequests} × ' + '${formatBytes(plan.windowSize)} across bytes ${plan.start}-${plan.end}'; + } + return 'Fixed range bytes=${plan.start}-${plan.end} ' + '(${formatBytes(plan.length)} per request)'; +} + +/// Renders a run's configuration and results as indented JSON. +String buildJsonReport({ + required BenchmarkStats stats, + BenchmarkConfig? config, + Uri? targetUrl, + String? status, +}) { + Map timing(TimingStats? timing) { + if (timing == null) return {}; + return { + 'samples': timing.count, + 'avg_us': timing.avgMicros.round(), + 'p50_us': timing.p50Micros, + 'p90_us': timing.p90Micros, + 'p99_us': timing.p99Micros, + 'min_us': timing.minMicros, + 'max_us': timing.maxMicros, + }; + } + + final plan = config?.rangePlan; + final report = { + 'source_url': config?.sourceUrl.toString(), + 'target_url': targetUrl?.toString(), + 'cache_type': config?.type.name, + 'cache_type_label': config?.type.label, + 'status': status, + 'concurrency': config?.concurrency, + 'http_client': config?.clientOption.label, + 'range': { + 'mode': plan == null + ? 'full' + : plan.isSequential + ? 'sequential' + : 'fixed', + 'description': config == null ? null : describeRangePlan(config), + if (plan != null) ...{ + 'start': plan.start, + 'end': plan.end, + 'window_size': plan.windowSize, + 'region_length': plan.length, + 'first_window': plan.windowFor(0).header, + }, + }, + 'requests': { + 'total': stats.totalRequests, + 'completed': stats.completed, + 'verified': stats.succeeded, + 'unverified': stats.unverified, + 'length_mismatches': stats.lengthMismatches, + 'http_errors': stats.httpErrors, + 'failures': stats.failures, + }, + 'throughput': { + 'elapsed_us': stats.elapsed.inMicroseconds, + 'requests_per_second': _round(stats.requestsPerSecond), + 'bytes_per_second': _round(stats.bytesPerSecond), + 'total_bytes': stats.totalBytes, + 'avg_bytes_per_request': _round(stats.avgBytesPerRequest), + }, + 'timings': { + 'response_headers': timing(stats.headerTime), + 'first_byte': timing(stats.firstByteTime), + 'completion': timing(stats.completionTime), + }, + }; + + return const JsonEncoder.withIndent(' ').convert(report); +} + +/// Renders a run's configuration and results as an aligned plain-text summary. +String buildTextReport({ + required BenchmarkStats stats, + BenchmarkConfig? config, + Uri? targetUrl, + String? status, +}) { + final buffer = StringBuffer() + ..writeln( + 'http_cache_stream benchmark' + '${config == null ? '' : ' — ${config.type.label}'}', + ); + + void field(String label, String? value) { + if (value == null) return; + buffer.writeln('${'$label:'.padRight(13)}$value'); + } + + field('Source', config?.sourceUrl.toString()); + field('Target', targetUrl?.toString()); + field('Client', config?.clientOption.label); + field( + 'Concurrency', + config == null ? null : '${config.concurrency} worker isolates', + ); + field('Range', config == null ? null : describeRangePlan(config)); + field('Status', status); + + buffer + ..writeln() + ..writeln( + '${'Requests:'.padRight(13)}${stats.completed} / ${stats.totalRequests} ' + 'completed · ${stats.succeeded} verified, ${stats.unverified} ' + 'unverified, ${stats.lengthMismatches} byte mismatch, ' + '${stats.httpErrors} HTTP error, ${stats.failures} failure', + ) + ..writeln('${'Elapsed:'.padRight(13)}${formatDuration(stats.elapsed)}') + ..writeln( + '${'Throughput:'.padRight(13)}' + '${formatRate(stats.requestsPerSecond, 'req/s')} · ' + '${formatBytesPerSecond(stats.bytesPerSecond)}', + ) + ..writeln( + '${'Bytes:'.padRight(13)}${formatBytes(stats.totalBytes)} total · ' + '${formatBytes(stats.avgBytesPerRequest)} per request', + ) + ..writeln(); + + const columns = ['avg', 'p50', 'p90', 'p99', 'min', 'max']; + const labelWidth = 18; + const columnWidth = 11; + + buffer.writeln( + 'Timing'.padRight(labelWidth) + + columns.map((column) => column.padLeft(columnWidth)).join(), + ); + + void timingRow(String label, TimingStats? timing) { + final values = timing == null + ? List.filled(columns.length, '—') + : [ + formatDuration(timing.avg), + formatDuration(timing.p50), + formatDuration(timing.p90), + formatDuration(timing.p99), + formatDuration(timing.min), + formatDuration(timing.max), + ]; + buffer.writeln( + label.padRight(labelWidth) + + values.map((value) => value.padLeft(columnWidth)).join(), + ); + } + + timingRow('Response headers', stats.headerTime); + timingRow('First byte', stats.firstByteTime); + timingRow('Completion', stats.completionTime); + + return buffer.toString(); +} + +/// Keeps floating point values readable in reports. +double _round(double value) => (value * 100).roundToDouble() / 100; diff --git a/benchmarker/lib/src/ui/benchmark_page.dart b/benchmarker/lib/src/ui/benchmark_page.dart index a40817f..4b0824f 100644 --- a/benchmarker/lib/src/ui/benchmark_page.dart +++ b/benchmarker/lib/src/ui/benchmark_page.dart @@ -90,7 +90,12 @@ class _BenchmarkPageState extends State { cacheUrl: _controller.targetUrl, ) : null; - final stats = StatsPanel(stats: _controller.stats, status: _status); + final stats = StatsPanel( + stats: _controller.stats, + status: _status, + config: _controller.config, + targetUrl: _controller.targetUrl, + ); final log = LogPanel( logs: _controller.logs, onClear: _controller.clearLogs, diff --git a/benchmarker/lib/src/ui/widgets/stats_panel.dart b/benchmarker/lib/src/ui/widgets/stats_panel.dart index 3c60df4..b36ff23 100644 --- a/benchmarker/lib/src/ui/widgets/stats_panel.dart +++ b/benchmarker/lib/src/ui/widgets/stats_panel.dart @@ -1,18 +1,65 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../benchmark/benchmark_config.dart'; +import '../../benchmark/benchmark_report.dart'; import '../../benchmark/benchmark_stats.dart'; import '../../util/formatting.dart'; import 'section_card.dart'; +/// Clipboard formats offered by the copy button. +enum _CopyFormat { json, text } + /// Aggregated results of the current or most recent run. class StatsPanel extends StatelessWidget { - const StatsPanel({super.key, required this.stats, required this.status}); + const StatsPanel({ + super.key, + required this.stats, + required this.status, + this.config, + this.targetUrl, + }); final BenchmarkStats? stats; /// Short status line shown next to the title, e.g. `Running`. final String status; + /// Inputs of the run the stats belong to, copied alongside them. + final BenchmarkConfig? config; + + /// URL the workers hit: the cache URL, or the source URL for direct runs. + final Uri? targetUrl; + + void _copy(BuildContext context, _CopyFormat format) { + final stats = this.stats; + if (stats == null) return; + final report = switch (format) { + _CopyFormat.json => buildJsonReport( + stats: stats, + config: config, + targetUrl: targetUrl, + status: status, + ), + _CopyFormat.text => buildTextReport( + stats: stats, + config: config, + targetUrl: targetUrl, + status: status, + ), + }; + Clipboard.setData(ClipboardData(text: report)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + format == _CopyFormat.json + ? 'Statistics copied as JSON.' + : 'Statistics copied as text.', + ), + ), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -21,6 +68,22 @@ class StatsPanel extends StatelessWidget { return SectionCard( title: 'Statistics', subtitle: status, + trailing: PopupMenuButton<_CopyFormat>( + enabled: stats != null, + tooltip: 'Copy statistics', + icon: const Icon(Icons.copy_all_outlined), + onSelected: (format) => _copy(context, format), + itemBuilder: (context) => const [ + PopupMenuItem<_CopyFormat>( + value: _CopyFormat.text, + child: Text('Copy as text'), + ), + PopupMenuItem<_CopyFormat>( + value: _CopyFormat.json, + child: Text('Copy as JSON'), + ), + ], + ), child: stats == null ? Padding( padding: const EdgeInsets.symmetric(vertical: 24), diff --git a/benchmarker/test/benchmark_controller_test.dart b/benchmarker/test/benchmark_controller_test.dart index 59165b5..567b446 100644 --- a/benchmarker/test/benchmark_controller_test.dart +++ b/benchmarker/test/benchmark_controller_test.dart @@ -169,7 +169,7 @@ void main() { expect(stats.avgBytesPerRequest, range.length.toDouble()); expect( controller.logs.map((entry) => entry.message), - contains(contains('Range bytes=1024-5119')), + contains(contains('Fixed range bytes=1024-5119')), ); }); diff --git a/benchmarker/test/benchmark_report_test.dart b/benchmarker/test/benchmark_report_test.dart new file mode 100644 index 0000000..8fb44ad --- /dev/null +++ b/benchmarker/test/benchmark_report_test.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +import 'package:benchmarker/src/benchmark/benchmark_config.dart'; +import 'package:benchmarker/src/benchmark/benchmark_report.dart'; +import 'package:benchmarker/src/benchmark/benchmark_stats.dart'; +import 'package:benchmarker/src/benchmark/http_client_builder.dart'; +import 'package:benchmarker/src/benchmark/worker_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BenchmarkConfig _config({RangePlan? rangePlan, int total = 4}) { + return BenchmarkConfig( + sourceUrl: Uri.parse('https://example.com/file.bin'), + concurrency: 2, + totalRequests: total, + type: BenchmarkType.preCached, + clientOption: kHttpClientOptions.first, + rangePlan: rangePlan, + ); +} + +BenchmarkStats _stats({int completed = 4}) { + final accumulator = StatsAccumulator(4); + for (var i = 0; i < completed; i++) { + accumulator.add( + RequestResult( + workerId: i % 2, + sequence: i, + outcome: RequestOutcome.success, + totalMicros: 1000 + i, + headerMicros: 100 + i, + firstByteMicros: 200 + i, + bytesReceived: 1024, + contentLength: 1024, + statusCode: 200, + ), + ); + } + return accumulator.snapshot(const Duration(seconds: 2)); +} + +void main() { + group('describeRangePlan', () { + test('describes a full response', () { + expect(describeRangePlan(_config()), 'Full response (no Range header)'); + }); + + test('describes a fixed range', () { + expect( + describeRangePlan( + _config(rangePlan: RangePlan.fixed(const ByteRange(1024, 5119))), + ), + 'Fixed range bytes=1024-5119 (4.00 KB per request)', + ); + }); + + test('describes sequential windows', () { + expect( + describeRangePlan( + _config( + rangePlan: RangePlan.sequential(const ByteRange(0, 4095), 4), + total: 4, + ), + ), + 'Sequential windows: 4 × 1.00 KB across bytes 0-4095', + ); + }); + }); + + group('buildJsonReport', () { + test('carries the run inputs and results', () { + final json = jsonDecode( + buildJsonReport( + stats: _stats(), + config: _config( + rangePlan: RangePlan.sequential(const ByteRange(0, 4095), 4), + ), + targetUrl: Uri.parse('http://127.0.0.1:4612/https/example.com/f.bin'), + status: 'Finished', + ), + ) as Map; + + expect(json['source_url'], 'https://example.com/file.bin'); + expect(json['target_url'], 'http://127.0.0.1:4612/https/example.com/f.bin'); + expect(json['cache_type'], 'preCached'); + expect(json['cache_type_label'], 'Pre-cached'); + expect(json['status'], 'Finished'); + expect(json['concurrency'], 2); + expect(json['http_client'], kHttpClientOptions.first.label); + + final range = json['range']! as Map; + expect(range['mode'], 'sequential'); + expect(range['start'], 0); + expect(range['end'], 4095); + expect(range['window_size'], 1024); + expect(range['first_window'], 'bytes=0-1023'); + + final requests = json['requests']! as Map; + expect(requests['total'], 4); + expect(requests['completed'], 4); + expect(requests['verified'], 4); + expect(requests['failures'], 0); + + final throughput = json['throughput']! as Map; + expect(throughput['elapsed_us'], 2000000); + expect(throughput['requests_per_second'], 2); + expect(throughput['total_bytes'], 4096); + + final timings = json['timings']! as Map; + final completion = timings['completion']! as Map; + expect(completion['samples'], 4); + expect(completion['min_us'], 1000); + expect(completion['max_us'], 1003); + }); + + test('marks a full-response run and omits range bounds', () { + final json = jsonDecode( + buildJsonReport(stats: _stats(), config: _config()), + ) as Map; + + final range = json['range']! as Map; + expect(range['mode'], 'full'); + expect(range.containsKey('window_size'), isFalse); + }); + + test('renders without a config', () { + final json = jsonDecode(buildJsonReport(stats: _stats())) + as Map; + + expect(json['source_url'], isNull); + expect((json['requests']! as Map)['completed'], 4); + }); + }); + + group('buildTextReport', () { + test('lists the run inputs above the results', () { + final text = buildTextReport( + stats: _stats(), + config: _config( + rangePlan: RangePlan.fixed(const ByteRange(1024, 5119)), + ), + targetUrl: Uri.parse('http://127.0.0.1:4612/https/example.com/f.bin'), + status: 'Finished', + ); + + expect(text, contains('http_cache_stream benchmark — Pre-cached')); + expect(text, contains('Source: https://example.com/file.bin')); + expect(text, contains('Target: http://127.0.0.1:4612/')); + expect(text, contains('Client: ${kHttpClientOptions.first.label}')); + expect(text, contains('Concurrency: 2 worker isolates')); + expect(text, contains('Range: Fixed range bytes=1024-5119')); + expect(text, contains('Status: Finished')); + expect(text, contains('Requests: 4 / 4 completed · 4 verified')); + expect(text, contains('Elapsed: 2.00 s')); + expect(text, contains('Throughput: 2.00 req/s')); + expect(text, contains('Bytes: 4.00 KB total')); + }); + + test('aligns the timing table and marks missing series', () { + final text = buildTextReport( + stats: const BenchmarkStats.empty(10), + config: _config(), + status: 'Idle', + ); + + final lines = text.split('\n'); + final header = lines.firstWhere((line) => line.startsWith('Timing')); + final completion = + lines.firstWhere((line) => line.startsWith('Completion')); + expect(header.length, completion.length); + expect(completion, contains('—')); + }); + }); +} diff --git a/benchmarker/test/stats_panel_test.dart b/benchmarker/test/stats_panel_test.dart new file mode 100644 index 0000000..a9ec2d2 --- /dev/null +++ b/benchmarker/test/stats_panel_test.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:benchmarker/src/benchmark/benchmark_config.dart'; +import 'package:benchmarker/src/benchmark/benchmark_stats.dart'; +import 'package:benchmarker/src/benchmark/http_client_builder.dart'; +import 'package:benchmarker/src/benchmark/worker_protocol.dart'; +import 'package:benchmarker/src/ui/widgets/stats_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final config = BenchmarkConfig( + sourceUrl: Uri.parse('https://example.com/file.bin'), + concurrency: 2, + totalRequests: 2, + type: BenchmarkType.nonCached, + clientOption: kHttpClientOptions.first, + rangePlan: RangePlan.sequential(const ByteRange(0, 2047), 2), + ); + + BenchmarkStats statsFor(int completed) { + final accumulator = StatsAccumulator(2); + for (var i = 0; i < completed; i++) { + accumulator.add( + RequestResult( + workerId: i, + sequence: i, + outcome: RequestOutcome.success, + totalMicros: 1500, + headerMicros: 250, + firstByteMicros: 400, + bytesReceived: 1024, + contentLength: 1024, + statusCode: 206, + ), + ); + } + return accumulator.snapshot(const Duration(seconds: 1)); + } + + /// Captures whatever the panel writes to the clipboard. + String? copied; + + setUp(() { + copied = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + Future pumpPanel(WidgetTester tester, {BenchmarkStats? stats}) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: StatsPanel( + stats: stats, + status: 'Finished', + config: config, + targetUrl: Uri.parse('http://127.0.0.1:4612/https/example.com/f'), + ), + ), + ), + ), + ); + } + + testWidgets('copies the run and its statistics as text', (tester) async { + await pumpPanel(tester, stats: statsFor(2)); + + await tester.tap(find.byIcon(Icons.copy_all_outlined)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Copy as text')); + await tester.pumpAndSettle(); + + expect(copied, contains('https://example.com/file.bin')); + expect(copied, contains('http://127.0.0.1:4612/https/example.com/f')); + expect(copied, contains('Non-cached')); + expect(copied, contains('Sequential windows: 2 × 1.00 KB')); + expect(copied, contains('Requests: 2 / 2 completed')); + expect(copied, contains('Completion')); + expect(find.text('Statistics copied as text.'), findsOneWidget); + }); + + testWidgets('copies the run and its statistics as JSON', (tester) async { + await pumpPanel(tester, stats: statsFor(2)); + + await tester.tap(find.byIcon(Icons.copy_all_outlined)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Copy as JSON')); + await tester.pumpAndSettle(); + + final json = jsonDecode(copied!) as Map; + expect(json['source_url'], 'https://example.com/file.bin'); + expect(json['target_url'], 'http://127.0.0.1:4612/https/example.com/f'); + expect(json['cache_type'], 'nonCached'); + expect(json['status'], 'Finished'); + expect((json['range']! as Map)['mode'], 'sequential'); + expect((json['requests']! as Map)['completed'], 2); + expect(find.text('Statistics copied as JSON.'), findsOneWidget); + }); + + testWidgets('the copy button is disabled before the first run', + (tester) async { + await pumpPanel(tester); + + final button = tester.widget( + find.ancestor( + of: find.byIcon(Icons.copy_all_outlined), + matching: find.byType(IconButton), + ), + ); + expect(button.onPressed, isNull); + }); +}