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..1ce9fcc --- /dev/null +++ b/benchmarker/README.md @@ -0,0 +1,135 @@ +# 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). | +| Request range | Which bytes each request asks for: a mode plus a slider bounding the region. | + +### Request range + +| 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 +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 + +| 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. + +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 +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_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 + 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/ + benchmark_form.dart input state, owned by the page + benchmark_page.dart page layout + widgets/ config, progress, stats and log 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 0000000..db77bb4 Binary files /dev/null and b/benchmarker/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ 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 0000000..17987b7 Binary files /dev/null and b/benchmarker/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/benchmarker/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/benchmarker/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/benchmarker/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ 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 0000000..d5f1c8d Binary files /dev/null and b/benchmarker/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ 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 0000000..4d6372e Binary files /dev/null and b/benchmarker/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ 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 0000000..dc9ada4 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ 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 0000000..797d452 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ 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 0000000..6ed2d93 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ 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 0000000..4cd7b00 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ 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 0000000..fe73094 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ 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 0000000..502f463 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ 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 0000000..0ec3034 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ 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 0000000..e9f5fea Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ 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 0000000..0467bf1 Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ 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 0000000..9da19ea Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ 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 0000000..9da19ea Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ 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 0000000..9da19ea Binary files /dev/null and b/benchmarker/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ 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..a79d47c --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_config.dart @@ -0,0 +1,268 @@ +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; +} + +/// 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 content length is unknown or the + /// selection is empty. + static ByteRange? fromFractions( + double startFraction, + double endFraction, + int contentLength, + ) { + final bounds = resolveBounds(startFraction, endFraction, contentLength); + if (bounds == null) return null; + 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'; +} + +/// 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({ + required this.sourceUrl, + required this.concurrency, + required this.totalRequests, + required this.type, + required this.clientOption, + this.rangePlan, + }); + + /// 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; + + /// Which bytes each request asks for, or null to request full responses. + final RangePlan? rangePlan; + + /// 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..abe3dbd --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_controller.dart @@ -0,0 +1,494 @@ +import 'dart:async'; +import 'dart:io'; + +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_report.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 _warnedRangeIgnored = 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; + _warnedRangeIgnored = 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}', + ); + if (config.rangePlan case final plan?) { + _log( + '${describeRangePlan(config)}' + '${plan.isSequential ? '; each request asks for the next window.' : '.'}', + ); + } + + _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, + rangePlan: config.rangePlan, + ), + ); + 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 (_config?.rangePlan != 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( + '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_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/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..dce6ef0 --- /dev/null +++ b/benchmarker/lib/src/benchmark/benchmark_worker.dart @@ -0,0 +1,208 @@ +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 sequence = job.firstSequence + i; + final result = await _executeRequest( + uri, + sequence, + job.rangePlan?.windowFor(sequence).header, + ); + 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, + String? rangeHeader, + ) async { + final stopwatch = Stopwatch()..start(); + int? headerMicros; + int? firstByteMicros; + var bytesReceived = 0; + + try { + 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) { + 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/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_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..b968b37 --- /dev/null +++ b/benchmarker/lib/src/benchmark/worker_protocol.dart @@ -0,0 +1,182 @@ +import 'dart:isolate'; + +import 'benchmark_config.dart'; +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, + this.rangePlan, + }); + + 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; + + /// 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]. +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_form.dart b/benchmarker/lib/src/ui/benchmark_form.dart new file mode 100644 index 0000000..c0b4977 --- /dev/null +++ b/benchmarker/lib/src/ui/benchmark_form.dart @@ -0,0 +1,262 @@ +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. +/// +/// 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, + RangeMode rangeMode = RangeMode.full, + this.probe = probeSource, + }) : _rangeMode = rangeMode, + urlController = TextEditingController(text: url), + concurrencyController = + TextEditingController(text: concurrency.toString()), + requestsController = + TextEditingController(text: totalRequests.toString()), + _type = type, + _clientOption = clientOption ?? kHttpClientOptions.first { + urlController.addListener(_onUrlChanged); + } + + static const String defaultUrl = + 'https://download.samplelib.com/mp3/sample-15s.mp3'; + + final TextEditingController urlController; + 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; + + RangeMode _rangeMode; + 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) { + 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; + + // --------------------------------------------------------------------------- + // 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'; + } + if (!canSelectRange) _rangeMode = RangeMode.full; + 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); + _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; + + 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 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; + 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 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 + // --------------------------------------------------------------------------- + + /// 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()); + + var error = BenchmarkConfig.validate( + url: url, + concurrency: concurrency, + totalRequests: totalRequests, + ); + if (error == null && _rangeMode.needsContentLength && isEmptySelection) { + error = 'The selected range is empty.'; + } + + 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, + rangePlan: buildRangePlan(totalRequests), + ); + } + + @override + void dispose() { + urlController.removeListener(_onUrlChanged); + 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 new file mode 100644 index 0000000..4b0824f --- /dev/null +++ b/benchmarker/lib/src/ui/benchmark_page.dart @@ -0,0 +1,159 @@ +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'; +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(); + + /// 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(); + } + + 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( + form: _form, + 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, + config: _controller.config, + targetUrl: _controller.targetUrl, + ); + 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..097a774 --- /dev/null +++ b/benchmarker/lib/src/ui/widgets/config_panel.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; +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'; + +/// The benchmark inputs: source URL, concurrency, total requests, run type and +/// http client implementation. +/// +/// 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; + + void _run() { + final config = form.buildConfig(); + if (config != null) onRun(config); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SectionCard( + title: 'Configuration', + subtitle: 'Requests are divided evenly between worker isolates.', + 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: 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: form.requestsController, + enabled: !isBusy, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Total requests', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + _RangeSelector(form: form, isBusy: isBusy), + 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: 8), + Text( + form.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: 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( + form.clientOption.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + 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'), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// 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) { + // 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 && form.rangeMode.needsContentLength; + final bounds = contentLength == null + ? null + : ByteRange.resolveBounds( + form.rangeFraction.start, + form.rangeFraction.end, + contentLength, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Request 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', + ), + ), + ], + ), + 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, + 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(contentLength), + style: theme.textTheme.bodySmall?.copyWith( + color: form.rangeMode.needsContentLength && 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, + ), + ), + ), + ], + ); + } + + /// 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/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..b36ff23 --- /dev/null +++ b/benchmarker/lib/src/ui/widgets/stats_panel.dart @@ -0,0 +1,333 @@ +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, + 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); + final stats = this.stats; + + 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), + 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 0000000..82b6f9d Binary files /dev/null and b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ 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 0000000..13b35eb Binary files /dev/null and b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ 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 0000000..0a3f5fa Binary files /dev/null and b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ 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 0000000..2f1632c Binary files /dev/null and b/benchmarker/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/benchmarker/macos/Runner/Base.lproj/MainMenu.xib b/benchmarker/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/benchmarker/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..2e87cc7 --- /dev/null +++ b/benchmarker/test/benchmark_config_test.dart @@ -0,0 +1,226 @@ +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('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('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), const ByteRange(0, 999)); + }); + + 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('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( + 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..567b446 --- /dev/null +++ b/benchmarker/test/benchmark_controller_test.dart @@ -0,0 +1,253 @@ +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'; + +/// 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() { + final payload = List.generate(256 * 1024, (index) => index % 256); + late HttpServer origin; + late Directory cacheDir; + 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 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); + } 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(); + } + }()); + // 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, + RangePlan? rangePlan, + }) { + return BenchmarkConfig( + sourceUrl: sourceUrl, + concurrency: workers, + totalRequests: total, + type: type, + clientOption: kHttpClientOptions.first, + rangePlan: rangePlan, + ); + } + + 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('direct run requests only the selected byte range', () async { + const range = ByteRange(1024, 5119); + await controller.start( + configFor(BenchmarkType.direct, rangePlan: RangePlan.fixed(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('Fixed 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, rangePlan: RangePlan.fixed(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('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)); + 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_form_test.dart b/benchmarker/test/benchmark_form_test.dart new file mode 100644 index 0000000..ea8d560 --- /dev/null +++ b/benchmarker/test/benchmark_form_test.dart @@ -0,0 +1,199 @@ +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'; +import 'package:flutter_test/flutter_test.dart'; + +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( + contentLength: contentLength, + acceptsRanges: acceptsRanges, + ); + }, + ); +} + +void main() { + 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); + + 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', + () 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); + expect(form.isFullRange, isTrue); + // Still in full-response mode, so no Range header is sent. + expect(form.rangeMode, RangeMode.full); + expect(form.buildConfig()!.rangePlan, isNull); + }); + + 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 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 { + final form = _form(contentLength: 1000); + addTearDown(form.dispose); + await form.fetchSourceLength(); + + form.rangeMode = RangeMode.fixed; + 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('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'; + + 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()!.rangePlan, 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); + expect(form.rangeMode, RangeMode.full); + }); + + 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/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, + ); + }); +} 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/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/config_panel_test.dart b/benchmarker/test/config_panel_test.dart new file mode 100644 index 0000000..ca2527e --- /dev/null +++ b/benchmarker/test/config_panel_test.dart @@ -0,0 +1,138 @@ +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() { + 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); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ConfigPanel( + form: form, + isBusy: false, + canCancel: false, + 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); + + expect(slider().onChanged, isNull); + 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); + }); + + 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(); + + form.rangeFraction = const RangeValues(0.25, 0.75); + await tester.pumpAndSettle(); + expect(find.textContaining('Every request: Range bytes=250-749'), + findsOneWidget); + + await tester.tap(find.text('Run benchmark')); + await tester.pumpAndSettle(); + + final plan = started!.rangePlan!; + expect(plan.isSequential, isFalse); + expect(plan.windowFor(0), const ByteRange(250, 749)); + }); + + 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(); + + 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(); + + 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/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); + }); +} diff --git a/benchmarker/test/worker_pool_test.dart b/benchmarker/test/worker_pool_test.dart new file mode 100644 index 0000000..da63191 --- /dev/null +++ b/benchmarker/test/worker_pool_test.dart @@ -0,0 +1,216 @@ +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'; +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, + 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; + request.response.add(payload); + case '/error': + request.response.statusCode = HttpStatus.notFound; + default: + final range = _parseRange(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(); + } + }()); + 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); + final receivedRanges = []; + late HttpServer origin; + late WorkerPool pool; + + setUp(() async { + receivedRanges.clear(); + origin = await _startOrigin(payload, receivedRanges); + 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, { + RangePlan? rangePlan, + }) 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, + rangePlan: rangePlan, + ), + ); + 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('a fixed range plan yields a verified partial response', () async { + final results = await run( + '/payload.bin', + [2, 1], + rangePlan: RangePlan.fixed(const ByteRange(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); + } + 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 { + 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 0000000..c04e20c Binary files /dev/null and b/benchmarker/windows/runner/resources/app_icon.ico differ 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_