Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c7d744e
docs: add Sentry integration plan
claude May 1, 2026
fd33ffc
docs(sentry-plan): config plugin, native telemetry, capture toggle
claude May 1, 2026
0892aea
feat(bench): UDS / RPC bridge benchmark suite
claude May 1, 2026
7f8f047
fix(bench): Android variant resolution + Expo SDK pinning
gmaclennan May 1, 2026
e7b9a52
fix(bench): iOS resource ordering + shutdown race + Copilot review
claude May 1, 2026
5acd807
Merge remote-tracking branch 'origin/main' into claude/benchmark-uds-…
gmaclennan May 3, 2026
214d9b0
feat(module): add comapeoBackendDir override hook
gmaclennan May 5, 2026
97ea28d
refactor(bench): move bench backend + plugin into apps/benchmark
gmaclennan May 5, 2026
634b3db
refactor(module): drop bench-specific build wiring
gmaclennan May 5, 2026
c1bd188
refactor(api): rename benchMessagePort to unstable_messagePort
gmaclennan May 5, 2026
d6ff5f6
refactor(backend): revert rollup.config.ts to main
gmaclennan May 5, 2026
aa56b90
docs: drop sentry-integration-plan.md from this branch
gmaclennan May 5, 2026
944bdf7
fix(bench): create Resources PBXGroup before adding folder ref
gmaclennan May 5, 2026
080295e
docs: drop benchmark plan; refresh App.tsx header
gmaclennan May 5, 2026
33118dd
docs: add bench app README
gmaclennan May 5, 2026
94b4e53
feat(bench): wire BrowserStack runner + host-side span receiver
gmaclennan May 5, 2026
9063023
feat(bench): make BrowserStack project sticky; scaffold RESULTS.md
gmaclennan May 5, 2026
b11140e
docs(bench): clarify BENCH_BROWSERSTACK_PROJECT is exceptional
gmaclennan May 5, 2026
e64053b
fix(bench): correct BrowserStack execute path + enable device logs
gmaclennan May 5, 2026
7c08575
feat(module): add comapeoStubRootKey override for keystoreless devices
gmaclennan May 5, 2026
235fdbb
feat(bench): wire BS Local + cleartext fetch; first real-run results
gmaclennan May 5, 2026
190ca17
feat(bench): wire backend args + multi-device runner + summarizer
gmaclennan May 6, 2026
c5e70a7
refactor(bench): move Maestro flows under apps/benchmark/.maestro/
gmaclennan May 6, 2026
1190e01
refactor(bench): switch span transport from HTTP receiver to logcat
gmaclennan May 6, 2026
6548b9b
feat(bench): runner — Maestro 2.0.7, log-pull, auto-batch, 10-device …
gmaclennan May 6, 2026
7c69745
docs(bench): update README + RESULTS for log-based transport
gmaclennan May 6, 2026
93dd41f
feat(bench): iOS parity — pipe nodejs-mobile stdout to os_log + fix R…
gmaclennan May 6, 2026
b1efb15
feat(bench): add ios:archive script for BS-ready Development IPA
gmaclennan May 6, 2026
95787eb
fix(bench): route RN-side spans through ingestSpans RPC, fix summarizer
gmaclennan May 6, 2026
f6a3da6
docs(bench): clean up stale receiver/HttpSink references
gmaclennan May 6, 2026
d5fbcf5
refactor(bench): drop HttpSink, bench-receiver, ios maestro flow
gmaclennan May 6, 2026
fc3905d
feat(bench): tag backend rpc spans, surface bridge overhead in summary
gmaclennan May 6, 2026
50e1bad
fix(ios): gate nodejs-mobile stdout→os_log redirect behind plist flag
gmaclennan May 6, 2026
a6c277c
docs: plan for manual-trigger benchmark CI with artifact storage
gmaclennan May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# BrowserStack App Automate credentials. Copy this file to .env and
# fill in the values from `Account → Settings → Access Keys` in the
# BrowserStack dashboard. Both scripts in scripts/ read these via
# `node --env-file=.env`.
BROWSERSTACK_USERNAME=
BROWSERSTACK_ACCESS_KEY=

# Optional escape hatch: pin builds to an existing BrowserStack
# project rather than letting BS auto-create one from the bundle ID.
# Leave unset for the normal path — the runner sends no `project`
# field and BrowserStack auto-creates a project named after the
# uploaded app's bundle ID. Only set this if your access key lacks
# project-creation permission AND you want to attach builds to a
# pre-existing project. Use the project name as it appears in the
# App Automate dashboard.
# BENCH_BROWSERSTACK_PROJECT=

# Apple Developer Team ID (10-char identifier). Required by
# `apps/benchmark/scripts/build-ipa.sh` to archive the bench app for
# BrowserStack iOS dispatches. Visible at the top right of any page on
# https://developer.apple.com/account/. The bundle id
# `com.comapeo.core.benchmark` must already be registered as an
# Identifier under the same team. No Capabilities or App Store Connect
# entry are required — BrowserStack auto-resigns IPAs with their own
# provisioning profile on upload.
# APPLE_DEVELOPMENT_TEAM_ID=ABC1234DEF

16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,19 @@ build/

# eslint
.eslintcache

# Local secrets (BrowserStack credentials etc). The .example file is
# checked in and lists the variables; copy it to .env and fill in.
.env
.env.local
.env.*.local

# Bench result NDJSONs, written by `scripts/bench-summarize.ts` after
# pulling device logs from a BrowserStack build. Treat as build
# artifacts; copy out of this dir if you want to commit a specific
# run's results.
apps/benchmark/results/

# `apps/benchmark/scripts/build-ipa.sh` output (xcarchive + IPA staged
# for BrowserStack upload). Regenerable; never commit.
apps/benchmark/ios-build/
48 changes: 45 additions & 3 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,40 @@ def comapeoAbiFilters = {
return effective
}()

// Override for the assets subdirectory the loader reads `index.mjs`
// from. Defaults to `nodejs-project` (the production bundle path
// emitted by `scripts/build-backend.ts`). A consumer that ships its
// own backend bundle in a sibling assets directory (e.g.
// `nodejs-bench/`) sets `comapeoBackendDir=nodejs-bench` in its
// `android/gradle.properties` and supplies the directory at
// `app/src/main/assets/nodejs-bench/` itself; AGP's normal asset merge
// places it alongside `nodejs-project/` in the APK and the loader
// reads the override path. The bench app at `apps/benchmark/` uses
// this hook via its `with-comapeo-bench` config plugin.
def comapeoBackendDir = (rootProject.findProperty('comapeoBackendDir') ?: project.findProperty('comapeoBackendDir') ?: 'nodejs-project').toString()

// Opt-out for the keystore-backed rootkey. When set, the loader sends
// a deterministic 16-zero-byte stub on the init frame instead of
// calling `RootKeyStore.loadOrInitialize()`. Intended only for builds
// whose backend doesn't construct a `MapeoManager` and never reads
// the rootkey value (the benchmark app being the canonical case) —
// production consumers MUST leave this unset so real identity
// material is encrypted at rest. The keystore-backed path requires
// the device to have a user ECDH key (set up by the user via screen
// lock); BrowserStack's stock Samsung devices don't, which is why
// the bench app needs this hook.
def comapeoStubRootKey = (rootProject.findProperty('comapeoStubRootKey') ?: project.findProperty('comapeoStubRootKey') ?: 'false').toString() == 'true'

// Extra argv string passed verbatim to nodejs-mobile after the
// canonical positional args (socket paths, dataDir). The loader
// splits on whitespace at runtime, so this can hold one or more
// CLI flags. Default empty; production consumers leave it unset.
// The bench app uses it to override its telemetry sink (e.g.
// `--telemetry=file:/tmp/spans.ndjson`); the bench backend's
// default sink writes spans to stdout where Android logcat picks
// them up, so an empty value is the right path for the BS sweep.
def comapeoBackendArgs = (rootProject.findProperty('comapeoBackendArgs') ?: project.findProperty('comapeoBackendArgs') ?: '').toString()

def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
Expand Down Expand Up @@ -76,6 +110,13 @@ android {
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// Surfaces `comapeoBackendDir` to runtime Kotlin via
// `BuildConfig.COMAPEO_BACKEND_DIR`. The Java-string literal
// form (with embedded quotes) is what `buildConfigField`
// requires.
buildConfigField "String", "COMAPEO_BACKEND_DIR", "\"${comapeoBackendDir}\""
buildConfigField "boolean", "COMAPEO_STUB_ROOTKEY", "${comapeoStubRootKey}"
buildConfigField "String", "COMAPEO_BACKEND_ARGS", "\"${comapeoBackendArgs}\""
externalNativeBuild {
cmake {
cppFlags ''
Expand All @@ -87,6 +128,7 @@ android {
abiFilters(*comapeoAbiFilters)
}
}

lintOptions {
abortOnError false
}
Expand All @@ -98,9 +140,6 @@ android {
// native module instance × ABI). Both directories ship into the
// same APK `lib/<abi>/` segment.
jniLibs.srcDirs 'libnode/bin/', 'src/main/jniLibs/'
assets {
srcDirs 'src/main/assets'
}
}
}
externalNativeBuild {
Expand All @@ -111,6 +150,9 @@ android {
}
buildFeatures {
prefab true
// Required for `buildConfigField` above to actually generate
// the BuildConfig class; AGP 8.x flipped the default to off.
buildConfig true
}
packagingOptions {
excludes += [
Expand Down
56 changes: 46 additions & 10 deletions android/src/main/java/com/comapeo/core/NodeJSService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ private data class ErrorNativeMessage(

const val APK_LAST_UPDATE_TIME_KEY = "apk_last_update_time"
const val SHARED_PREFS_NAME_POSTFIX = "_nodejs_preferences"
const val NODEJS_PROJECT_DIRNAME = "nodejs-project"
// Asset subdirectory the loader copies into the app's filesDir and
// runs `index.mjs` from. Sourced from `BuildConfig.COMAPEO_BACKEND_DIR`
// so consumers can override via the `comapeoBackendDir` Gradle
// property (default `nodejs-project`). See android/build.gradle for
// the buildConfigField declaration.
val NODEJS_PROJECT_DIRNAME: String = BuildConfig.COMAPEO_BACKEND_DIR
const val NODEJS_PROJECT_INDEX_FILENAME = "index.mjs"

/**
Expand Down Expand Up @@ -409,15 +414,31 @@ class NodeJSService(
}
}

val exitCode = startNodeWithArguments(
arrayOf(
"node",
jsFile.absolutePath,
comapeoSocketFile.absolutePath,
controlSocketFile.absolutePath,
dataDir,
)
// Base argv the backend's positional parser expects:
// socket paths + dataDir. After that we append:
// - `--device=<MANUFACTURER MODEL (Android REL)>` —
// a stable device tag the bench backend uses for
// span attribution. Production backend ignores
// unknown flags so this is a no-op there.
// - whitespace-split tokens from
// `BuildConfig.COMAPEO_BACKEND_ARGS` (set by the
// `comapeoBackendArgs` Gradle property). Empty by
// default; consumers like the bench plugin set
// `--telemetry=<spec>` here when they want a
// non-default sink.
val baseArgs = arrayOf(
"node",
jsFile.absolutePath,
comapeoSocketFile.absolutePath,
controlSocketFile.absolutePath,
dataDir,
)
val deviceTag = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL} (Android ${android.os.Build.VERSION.RELEASE})"
val extraArgs = mutableListOf("--device=$deviceTag")
if (BuildConfig.COMAPEO_BACKEND_ARGS.isNotBlank()) {
extraArgs += BuildConfig.COMAPEO_BACKEND_ARGS.trim().split("\\s+".toRegex())
}
val exitCode = startNodeWithArguments(baseArgs + extraArgs.toTypedArray())
log("NodeJS service completed with exit code $exitCode")

// Classify the exit. "Requested" means we asked for it
Expand Down Expand Up @@ -526,7 +547,22 @@ class NodeJSService(
*/
private fun sendInitFrame() {
val rootKeyBytes: ByteArray = try {
RootKeyStore(applicationContext).loadOrInitialize()
if (BuildConfig.COMAPEO_STUB_ROOTKEY) {
// Deterministic 16-zero-byte stub for builds that opt
// out of keystore-backed rootkey persistence (see
// android/build.gradle for the property doc and the
// bench app's `with-comapeo-bench` plugin for where
// it's set). The receiver MUST be a backend that
// doesn't construct a `MapeoManager` — the bench
// backend's relaxed init handler is the canonical
// example. Real identity material is never derived
// from this stub, so production consumers are
// protected by `comapeoStubRootKey` being false by
// default.
ByteArray(16)
} else {
RootKeyStore(applicationContext).loadOrInitialize()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load rootkey", e)
val info = ErrorInfo("rootkey", e.message ?: e.javaClass.simpleName)
Expand Down
40 changes: 40 additions & 0 deletions apps/benchmark/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# dependencies
node_modules/

# Expo
.expo/
dist/
web-build/
expo-env.d.ts

# Native (regenerated by `expo prebuild`; the with-comapeo-bench config
# plugin re-applies bench wiring on every prebuild, so checking these
# in would only invite drift)
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision

# Metro
.metro-health-check*

# debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
.DS_Store
*.pem

# local env files
.env*.local

# typescript
*.tsbuildinfo

# expo prebuild
/android
/ios
28 changes: 28 additions & 0 deletions apps/benchmark/.maestro/bench-payload-1KB.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
appId: com.comapeo.core.benchmark
---
# Per-payload-size variant of bench-rpc.yaml: only 1KB selected.

- launchApp:
clearState: true

- extendedWaitUntil:
visible:
id: "service-state"
text: "STARTED"
timeout: 60000

- tapOn:
id: "size-64"
- tapOn:
id: "size-65536"

- tapOn:
id: "send-button"

- extendedWaitUntil:
visible:
id: "benchmark-result"
timeout: 60000

- assertVisible:
text: "1KB"
35 changes: 35 additions & 0 deletions apps/benchmark/.maestro/bench-payload-1MB.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
appId: com.comapeo.core.benchmark
---
# Per-payload-size variant of bench-rpc.yaml: only 1MB selected. Note
# 1MB is NOT in the default selection — the flow deselects the three
# defaults and selects 1MB explicitly. Timeout is bumped because each
# round-trip moves a megabyte through the bridge.

- launchApp:
clearState: true

- extendedWaitUntil:
visible:
id: "service-state"
text: "STARTED"
timeout: 60000

- tapOn:
id: "size-64"
- tapOn:
id: "size-1024"
- tapOn:
id: "size-65536"
- tapOn:
id: "size-1048576"

- tapOn:
id: "send-button"

- extendedWaitUntil:
visible:
id: "benchmark-result"
timeout: 180000

- assertVisible:
text: "1MB"
30 changes: 30 additions & 0 deletions apps/benchmark/.maestro/bench-payload-64B.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
appId: com.comapeo.core.benchmark
---
# Per-payload-size variant of bench-rpc.yaml: only 64B selected.
# Default selection is {64B, 1KB, 64KB}; deselect the two larger sizes
# so the run records 64B-only RTT distribution.

- launchApp:
clearState: true

- extendedWaitUntil:
visible:
id: "service-state"
text: "STARTED"
timeout: 60000

- tapOn:
id: "size-1024"
- tapOn:
id: "size-65536"

- tapOn:
id: "send-button"

- extendedWaitUntil:
visible:
id: "benchmark-result"
timeout: 60000

- assertVisible:
text: "64B"
28 changes: 28 additions & 0 deletions apps/benchmark/.maestro/bench-payload-64KB.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
appId: com.comapeo.core.benchmark
---
# Per-payload-size variant of bench-rpc.yaml: only 64KB selected.

- launchApp:
clearState: true

- extendedWaitUntil:
visible:
id: "service-state"
text: "STARTED"
timeout: 60000

- tapOn:
id: "size-64"
- tapOn:
id: "size-1024"

- tapOn:
id: "send-button"

- extendedWaitUntil:
visible:
id: "benchmark-result"
timeout: 90000

- assertVisible:
text: "64KB"
Loading
Loading