diff --git a/CLAUDE.md b/CLAUDE.md index fc431da2c5..8a5814912d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,8 @@ Do not: - Hedge with "generally", "typically", or "you may want to" when the answer is "do this". - Clear your throat: "It's worth noting that", "In order to", "This section covers". +When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures. + ## Quick start ```bash diff --git a/docs/android.md b/docs/android.md index c90a27efec..4920f8a728 100644 --- a/docs/android.md +++ b/docs/android.md @@ -64,36 +64,56 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted From repo root: ```bash -npm run android:development # Debug build -npm run android:production # Release build -npm run android:clear # Remove generated Android project +npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86 +npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones +npm run dev:android # Build/install the debug app and start development +npm run dev:ios # Build/install the iOS development app ``` -For a production-ID release APK that local Android profiling tools can attach to: +`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes +`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable +`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable +flag. It keeps the production package ID (`sh.paseo`) and release optimizations. Use this APK for +personal dogfooding. Normal store/release workflows leave diagnostics disabled. + +The older `android`, `android:development`, and `ios` commands remain aliases for development. +`android:production` still builds and installs a release through Expo; use `build:apk` when you +need an artifact. `android:clear` removes the generated Android project. + +### Diagnostic logs without ADB + +In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all +retained `.jsonl` files. No app screen, storage permission, or attached debugger is required. +The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation +replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall +can leave older Downloads files that the new installation cannot manage. + +Each file identifies its build and session. Events include application-transport frame sizes and +message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree +render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents +are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when already available, otherwise **UTF-16 code units** for text. These are +client transport observations, after relay decryption where applicable, not packet arrival times. + +The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval). +A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration. +Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked; +`js.stall.recovered` records the gap when JS resumes. Process death leaves earlier files intact, +but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash. + +`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include +faster renders. Nested profiler timings overlap: don't add parent and child times. React's render +measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped` +means the bounded queue overflowed; missing events in that interval must not be treated as absence +of activity. Storage failure stops recording rather than blocking the app. + +For an ordinary release APK with system trace markers only: ```bash PASEO_PROFILE_BUILD=1 npm run android:production ``` -This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds -`` and enables local Android trace markers for workspace mounts -and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents, -and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`). - -Or from `packages/app`: - -```bash -# Debug -npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive -npx cross-env APP_VARIANT=development expo run:android --variant=debug - -# Release -npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive -npx cross-env APP_VARIANT=production expo run:android --variant=release - -# Clear generated Android project -rm -rf android -``` +Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`); +they do not produce Downloads files. ## Running on an emulator against a worktree daemon diff --git a/package.json b/package.json index 3cdad0e523..1e946e8e18 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,16 @@ "dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"", "dev:server:raw": "npm run dev --workspace=@getpaseo/server", "dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh", + "dev:android": "npm run dev:android --workspace=@getpaseo/app", + "dev:ios": "npm run dev:ios --workspace=@getpaseo/app", "test:e2e:mobile": "./scripts/test-mobile-agent-device.sh", "test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh", "dev:website": "npm run dev --workspace=@getpaseo/website", "postinstall": "node scripts/postinstall-patches.mjs", "prepare": "lefthook install --force", "build": "npm run build --workspaces --if-present", + "build:apk": "node scripts/build-apk.mjs", + "build:apk:arm64": "node scripts/build-apk.mjs arm64", "build:highlight": "npm run build --workspace=@getpaseo/highlight", "build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight", "build:plugin": "npm run build --workspace=@getpaseo/plugin", @@ -80,15 +84,14 @@ "acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift", "acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update", "start": "npm run start --workspace=@getpaseo/server", - "android": "npm run android --workspace=@getpaseo/app", + "android": "npm run dev:android", "android:development": "npm run android:development --workspace=@getpaseo/app", "android:production": "npm run android:production --workspace=@getpaseo/app", "android:release": "npm run android:production --workspace=@getpaseo/app", "android:clear": "npm run android:clear --workspace=@getpaseo/app", - "android:clean": "npm run android:clean --workspace=@getpaseo/app", "fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs", "fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check", - "ios": "npm run ios --workspace=@getpaseo/app", + "ios": "npm run dev:ios", "web": "npm run web --workspace=@getpaseo/app", "dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop", "dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index e09a06f71d..6b3d5edd62 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -10,7 +10,8 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin"); const { getNativeReleaseVersion } = require("./native-release-version"); const appVariant = process.env.APP_VARIANT ?? "production"; const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1"; -const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1"; +const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1"; +const isProfileBuild = isWatchdogBuild || process.env.PASEO_PROFILE_BUILD === "1"; const buildProfile = isFdroidBuild ? { @@ -190,6 +191,9 @@ export default { extra: { fdroidBuild: isFdroidBuild, profileBuild: isProfileBuild, + watchdogBuild: isWatchdogBuild, + diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION, + diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME, router: {}, eas: { projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a", diff --git a/packages/app/index.ts b/packages/app/index.ts index 82d47bfe8c..16553c6886 100644 --- a/packages/app/index.ts +++ b/packages/app/index.ts @@ -9,6 +9,8 @@ import "@expo/metro-runtime"; // oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects. import "expo-router/build/fast-refresh"; import { renderRootComponent } from "expo-router/build/renderRootComponent"; +import { startDiagnostics } from "./src/diagnostics"; import { RootApp } from "./src/root-app"; +startDiagnostics(); renderRootComponent(RootApp); diff --git a/packages/app/metro.config.cjs b/packages/app/metro.config.cjs index c1fdbdef77..412364221b 100644 --- a/packages/app/metro.config.cjs +++ b/packages/app/metro.config.cjs @@ -16,6 +16,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "") .toLowerCase(); const config = getDefaultConfig(projectRoot); +config.cacheVersion = process.env.PASEO_WATCHDOG_BUILD === "1" ? "watchdog" : "standard"; const defaultResolveRequest = config.resolver.resolveRequest ?? resolve; // Keep app exports deterministic across dev machines and CI. Metro's Watchman @@ -75,6 +76,14 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) { } config.resolver.resolveRequest = (context, moduleName, platform) => { + if ( + process.env.PASEO_WATCHDOG_BUILD === "1" && + platform === "android" && + /[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) && + /implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName) + ) { + return defaultResolveRequest(context, moduleName.replace(/-prod$/, "-profiling"), platform); + } if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) { return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform); } diff --git a/packages/app/modules/paseo-native-trace/android/build.gradle b/packages/app/modules/paseo-native-trace/android/build.gradle index bfd0eec72a..c0340730ff 100644 --- a/packages/app/modules/paseo-native-trace/android/build.gradle +++ b/packages/app/modules/paseo-native-trace/android/build.gradle @@ -13,3 +13,7 @@ useDefaultAndroidSdkVersions() android { namespace "sh.paseo.trace" } + +dependencies { + testImplementation "junit:junit:4.13.2" +} diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt new file mode 100644 index 0000000000..1561f8f1cd --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt @@ -0,0 +1,129 @@ +package sh.paseo.trace + +import android.content.ContentValues +import android.content.Context +import android.os.SystemClock +import android.provider.MediaStore +import android.util.Log +import org.json.JSONObject +import java.io.OutputStream +import java.util.UUID +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** All serialization and storage belongs to the worker, never the JS/UI threads. */ +internal class DiagnosticRecorder(private val context: Context, private val metadata: Map) { + private data class Event(val name: String, val data: Map, val monoMs: Long = SystemClock.elapsedRealtime(), val wallMs: Long = System.currentTimeMillis()) + private val queue = ArrayBlockingQueue(2048) + private val dropped = AtomicLong() + private val worker = Executors.newSingleThreadScheduledExecutor() + private val session = UUID.randomUUID().toString() + private var output: OutputStream? = null + private var currentUri: android.net.Uri? = null + private var size = 0 + private var part = 0 + @Volatile private var failed = false + private val watchdog = HeartbeatWatchdog(SystemClock.elapsedRealtime(), ::record) + + init { + record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "250", "stallThresholdMs" to "500")) + worker.scheduleWithFixedDelay({ drain() }, 0, 250, TimeUnit.MILLISECONDS) + } + + fun record(name: String, data: Map) { + if (failed) return + // Limit native memory even if an instrumentation caller accidentally supplies large fields. + val bounded = data.entries.take(24).associate { it.key.take(80) to it.value.take(256) } + if (!queue.offer(Event(name.take(120), bounded))) dropped.incrementAndGet() + } + + fun heartbeat() = watchdog.heartbeat(SystemClock.elapsedRealtime()) + fun active(value: Boolean) = watchdog.active(value, SystemClock.elapsedRealtime()) + + private fun drain() { + if (failed) return + try { + watchdog.check(SystemClock.elapsedRealtime()) + val lost = dropped.getAndSet(0) + if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost"))) + // A traffic flood must not prevent watchdog checks or flushing. + var remaining = 2048 + while (remaining-- > 0) { + val event = queue.poll() ?: break + write(event) + } + // Closing each batch publishes size/modified time to MediaStore. Keeping a + // descriptor open makes the current file appear as 0 B in Android Files. + output?.close() + output = null + } catch (error: Exception) { + // Storage failure must never crash the app or create a retry storm. + failed = true + queue.clear() + runCatching { output?.close() } + output = null + Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error) + } + } + + private fun write(event: Event) { + val bytes = (JSONObject().apply { + put("event", event.name) + put("session", session) + put("wallMs", event.wallMs) + put("monoMs", event.monoMs) + put("data", JSONObject(event.data)) + }.toString() + "\n").toByteArray(Charsets.UTF_8) + if (currentUri == null || size + bytes.size > 5 * 1024 * 1024) rotate() + if (output == null) { + output = checkNotNull(context.contentResolver.openOutputStream(checkNotNull(currentUri), "wa")).buffered(64 * 1024) + } + output!!.write(bytes) + size += bytes.size + } + + private fun rotate() { + output?.close() + output = null + val resolver = context.contentResolver + val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "paseo-diagnostics-${System.currentTimeMillis()}-${session.take(8)}-${part++}.jsonl") + put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson") + put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/Paseo diagnostics/") + } + val uri = checkNotNull(resolver.insert(collection, values)) + currentUri = uri + output = checkNotNull(resolver.openOutputStream(uri, "w")).buffered(64 * 1024) + val header = (JSONObject().apply { + put("event", "file.start") + put("session", session) + put("wallMs", System.currentTimeMillis()) + put("monoMs", SystemClock.elapsedRealtime()) + put("data", JSONObject(metadata + mapOf("schema" to "1", "part" to "$part"))) + }.toString() + "\n").toByteArray(Charsets.UTF_8) + output!!.write(header) + size = header.size + // Only prune files created by this installation, never arbitrary user downloads. + resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?", + arrayOf("Download/Paseo diagnostics/", context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor -> + var count = 0 + while (cursor.moveToNext()) { + if (++count > 8) resolver.delete(android.content.ContentUris.withAppendedId(collection, cursor.getLong(0)), null, null) + } + } + } + + fun close() { + worker.execute { + record("session.end", emptyMap()) + drain() + runCatching { output?.close() } + output = null + } + worker.shutdown() + } +} diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt new file mode 100644 index 0000000000..11034c50e4 --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt @@ -0,0 +1,32 @@ +package sh.paseo.trace + +/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */ +internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map) -> Unit) { + private var foreground = true + private var lastHeartbeat = startMs + private var overdue = false + + @Synchronized fun heartbeat(now: Long) { + if (foreground && now - lastHeartbeat > 750) { + emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - 250}")) + } + lastHeartbeat = now + overdue = false + } + + @Synchronized fun active(value: Boolean, now: Long) { + if (foreground == value) return + emit("app.lifecycle", mapOf("state" to if (value) "foreground" else "background", "stallOpen" to "$overdue")) + foreground = value + lastHeartbeat = now + overdue = false + } + + @Synchronized fun check(now: Long) { + val gap = now - lastHeartbeat + if (foreground && gap > 750 && !overdue) { + emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat")) + overdue = true + } + } +} diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt index 4219993e36..59921b5f20 100644 --- a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt +++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt @@ -5,9 +5,22 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class PaseoNativeTraceModule : Module() { + private var recorder: DiagnosticRecorder? = null + override fun definition() = ModuleDefinition { Name("PaseoNativeTrace") + Function("startRecorder") { metadata: Map -> + if (recorder == null) { + appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) } + } + } + Function("record") { name: String, data: Map -> recorder?.record(name, data) } + Function("heartbeat") { recorder?.heartbeat() } + OnActivityEntersForeground { recorder?.active(true) } + OnActivityEntersBackground { recorder?.active(false) } + OnDestroy { recorder?.close(); recorder = null } + Function("beginSection") { name: String -> Trace.beginSection(name.take(127)) } diff --git a/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt new file mode 100644 index 0000000000..870c031997 --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt @@ -0,0 +1,40 @@ +package sh.paseo.trace + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HeartbeatWatchdogTest { + @Test fun recordsOneStartAndRecoveryWithMeasuredGap() { + val events = mutableListOf>>() + val watchdog = HeartbeatWatchdog(0) { name, fields -> events.add(name to fields) } + watchdog.heartbeat(250) + watchdog.check(1000) + assertEquals(emptyList>>(), events) + watchdog.check(1001) + watchdog.check(2000) + watchdog.heartbeat(2250) + assertEquals(listOf( + "js.stall.started" to mapOf("heartbeatGapMs" to "751", "lastHeartbeatMonoMs" to "250"), + "js.stall.recovered" to mapOf("heartbeatGapMs" to "2000", "overdueMs" to "1750") + ), events) + } + + @Test fun backgroundAndResumeDoNotLookLikeStalls() { + val events = mutableListOf() + val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) } + watchdog.active(false, 100) + watchdog.check(300000) + watchdog.heartbeat(300100) + watchdog.active(true, 400000) + watchdog.heartbeat(400250) + watchdog.check(400500) + assertEquals(listOf("app.lifecycle", "app.lifecycle"), events) + } + + @Test fun recoveryIsReportedEvenIfNativeSamplingMissedTheGap() { + val events = mutableListOf() + val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) } + watchdog.heartbeat(900) + assertEquals(listOf("js.stall.recovered"), events) + } +} diff --git a/packages/app/package.json b/packages/app/package.json index 0584846dd0..c8eae37f58 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,12 +8,12 @@ "start:expo": "cross-env APP_VARIANT=development expo start", "reset-project": "node ./scripts/reset-project.js", "eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview", - "android": "npm run android:development", - "android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", + "android": "npm run dev:android", + "android:development": "npm run dev:android", "android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release", "android:release": "npm run android:production", "android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"", - "ios": "npm --prefix ../.. run build:client && expo run:ios", + "ios": "npm run dev:ios", "ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release", "web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"", "web:expo": "expo start --web", @@ -36,7 +36,11 @@ "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", - "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs" + "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs", + "dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", + "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios", + "build:apk": "npm --prefix ../.. run build:apk", + "build:apk:arm64": "npm --prefix ../.. run build:apk:arm64" }, "dependencies": { "@codemirror/commands": "6.10.4", diff --git a/packages/app/src/diagnostics/index.ts b/packages/app/src/diagnostics/index.ts new file mode 100644 index 0000000000..5adf614bc8 --- /dev/null +++ b/packages/app/src/diagnostics/index.ts @@ -0,0 +1,35 @@ +import { requireOptionalNativeModule } from "expo-modules-core"; +import Constants from "expo-constants"; +import { resolveAppVersion } from "@/utils/app-version"; +import { createRecorder } from "./internal/recorder"; + +interface NativeRecorder { + startRecorder(metadata: Record): void; + record(name: string, fields: Record): void; + heartbeat(): void; +} + +const native = + Constants.expoConfig?.extra?.watchdogBuild === true + ? requireOptionalNativeModule("PaseoNativeTrace") + : null; +export const diagnostics = native + ? createRecorder( + (name, fields) => native.record(name, fields), + () => performance.now(), + ) + : null; +let started = false; + +export function startDiagnostics(): void { + if (!native || !diagnostics || started) return; + started = true; + native.startRecorder({ + version: resolveAppVersion() ?? "unknown", + build: String(Constants.expoConfig?.extra?.diagnosticRevision ?? "unknown"), + builtAt: String(Constants.expoConfig?.extra?.diagnosticBuildTime ?? "unknown"), + reactProfiling: "true", + }); + setInterval(() => native.heartbeat(), 250); + setInterval(() => diagnostics.flushRenders(), 1000); +} diff --git a/packages/app/src/diagnostics/internal/recorder.test.ts b/packages/app/src/diagnostics/internal/recorder.test.ts new file mode 100644 index 0000000000..072ca2a708 --- /dev/null +++ b/packages/app/src/diagnostics/internal/recorder.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "vitest"; +import { createRecorder } from "./recorder"; + +function fixture() { + const events: { name: string; fields: Record }[] = []; + let now = 0; + const recorder = createRecorder( + (name, fields) => events.push({ name, fields }), + () => now, + ); + return { + recorder, + events, + advance: (ms: number) => { + now += ms; + }, + }; +} + +test("interleaved hosts keep independent parent spans and durations", () => { + const { recorder, events, advance } = fixture(); + const a = recorder.trace({ host: "a" }); + const b = recorder.trace({ host: "b" }); + a.beginSection("frame"); + advance(5); + b.beginSection("frame"); + a.beginSection("parse"); + advance(15); + a.endSection(); + b.endSection(); + a.endSection(); + expect( + events.map(({ name, fields }) => [ + name, + fields.host, + fields.span, + fields.parent, + fields.durationMs, + ]), + ).toEqual([ + ["frame.begin", "a", "1:1", "", undefined], + ["frame.begin", "b", "2:1", "", undefined], + ["parse.begin", "a", "1:2", "1:1", undefined], + ["parse.end", "a", "1:2", undefined, "15"], + ["frame.end", "b", "2:1", undefined, "15"], + ["frame.end", "a", "1:1", undefined, "20"], + ]); +}); + +test("slow renders preserve commit identity while fast renders are summarized", () => { + const { recorder, events } = fixture(); + for (const actualDuration of [1, 2, 30]) + recorder.render({ + id: "Stream", + phase: "update", + actualDuration, + startTime: 4, + commitTime: 40, + }); + recorder.flushRenders(); + recorder.flushRenders(); + expect(events).toEqual([ + { + name: "react.render.slow", + fields: { + region: "Stream", + phase: "update", + renderMs: "30", + startMs: "4", + commitMs: "40", + jsMonoMs: "0", + }, + }, + { + name: "react.render.summary", + fields: { region: "Stream", count: "3", totalMs: "33", maxMs: "30", jsMonoMs: "0" }, + }, + ]); +}); + +test("dynamic regions are bounded and summaries reset each interval", () => { + const { recorder, events } = fixture(); + for (let i = 0; i < 10000; i++) + recorder.render({ + id: String(i), + phase: "mount", + actualDuration: 1, + startTime: 0, + commitTime: 1, + }); + recorder.flushRenders(); + expect(events.length).toBe(129); + expect(events.at(-1)?.fields).toEqual({ + region: "other", + count: "9872", + totalMs: "9872", + maxMs: "1", + jsMonoMs: "0", + }); + recorder.render({ id: "new", phase: "mount", actualDuration: 1, startTime: 0, commitTime: 1 }); + recorder.flushRenders(); + expect(events.at(-1)?.fields.region).toBe("new"); +}); diff --git a/packages/app/src/diagnostics/internal/recorder.ts b/packages/app/src/diagnostics/internal/recorder.ts new file mode 100644 index 0000000000..9af879abe6 --- /dev/null +++ b/packages/app/src/diagnostics/internal/recorder.ts @@ -0,0 +1,79 @@ +import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client"; + +type Fields = Record; +interface RenderSample { + id: string; + phase: string; + actualDuration: number; + startTime: number; + commitTime: number; +} + +/** Bounded render aggregation and scoped trace identity; no payloads enter this boundary. */ +export function createRecorder(write: (name: string, fields: Fields) => void, now: () => number) { + let nextClient = 0; + const renders = new Map(); + const record = (name: string, fields: Fields = {}) => + write(name, { ...fields, jsMonoMs: String(now()) }); + return { + record, + trace(context: Fields): DaemonClientTrace { + const client = String(++nextClient); + let sequence = 0; + const sections: { id: string; name: string; start: number }[] = []; + return { + isEnabled: () => true, + beginSection(name, fields) { + const id = `${client}:${++sequence}`; + record(`${name}.begin`, { + ...context, + ...fields, + client, + span: id, + parent: sections.at(-1)?.id ?? "", + }); + sections.push({ id, name, start: now() }); + }, + endSection() { + const section = sections.pop(); + if (section) + record(`${section.name}.end`, { + ...context, + client, + span: section.id, + durationMs: String(now() - section.start), + }); + }, + }; + }, + render(sample: RenderSample) { + if (sample.actualDuration >= 16) { + record("react.render.slow", { + region: sample.id, + phase: sample.phase, + renderMs: String(sample.actualDuration), + startMs: String(sample.startTime), + commitMs: String(sample.commitTime), + }); + } + // Dynamic agent/tab IDs cannot grow this map without bound. + const key = renders.has(sample.id) || renders.size < 128 ? sample.id : "other"; + const summary = renders.get(key) ?? { count: 0, totalMs: 0, maxMs: 0 }; + summary.count++; + summary.totalMs += sample.actualDuration; + summary.maxMs = Math.max(summary.maxMs, sample.actualDuration); + renders.set(key, summary); + }, + flushRenders() { + for (const [region, summary] of renders) { + record("react.render.summary", { + region, + count: String(summary.count), + totalMs: String(summary.totalMs), + maxMs: String(summary.maxMs), + }); + } + renders.clear(); + }, + }; +} diff --git a/packages/app/src/performance/native-trace.ts b/packages/app/src/performance/native-trace.ts index b5e8e73a60..8161527136 100644 --- a/packages/app/src/performance/native-trace.ts +++ b/packages/app/src/performance/native-trace.ts @@ -1,3 +1,4 @@ +import { diagnostics } from "@/diagnostics"; import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client"; import { requireOptionalNativeModule } from "expo-modules-core"; import { isProfileBuild } from "@/constants/build-profile"; @@ -30,9 +31,26 @@ function formatSectionName(name: string, args?: Record): string } export function traceInstant(name: string, args?: Record): void { + diagnostics?.record(name, args); if (!nativePerformanceTrace.isEnabled()) { return; } nativePerformanceTrace.beginSection(name, args); nativePerformanceTrace.endSection(); } + +/** Each client owns its section stack; overlapping hosts never share span identity. */ +export function createPerformanceTrace(context: Record): DaemonClientTrace { + const recorder = diagnostics?.trace(context); + return { + isEnabled: () => recorder !== undefined || nativePerformanceTrace.isEnabled(), + beginSection(name, args) { + recorder?.beginSection(name, args); + if (nativePerformanceTrace.isEnabled()) nativePerformanceTrace.beginSection(name, args); + }, + endSection() { + recorder?.endSection(); + if (nativePerformanceTrace.isEnabled()) nativePerformanceTrace.endSection(); + }, + }; +} diff --git a/packages/app/src/root-app.tsx b/packages/app/src/root-app.tsx index cae9b169d6..746a008d58 100644 --- a/packages/app/src/root-app.tsx +++ b/packages/app/src/root-app.tsx @@ -1,3 +1,4 @@ +import { RenderProfile } from "@/utils/render-profiler"; import { useCallback, useState, type ComponentProps } from "react"; import { ExpoRoot } from "expo-router"; import Head from "expo-router/head"; @@ -13,7 +14,11 @@ const { ctx: context } = require("expo-router/_ctx") as { }; export function RootApp() { - return ; + return ( + + + + ); } export function RootRouter({ context: routes }: Pick, "context">) { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 55298ce5a1..63d22db40d 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,3 +1,4 @@ +import { diagnostics } from "@/diagnostics"; import { useSyncExternalStore, useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; @@ -83,7 +84,7 @@ import { type ViewedTimelineOwnerPorts, } from "@/timeline/viewed-timeline-sync"; import { projectIconCache } from "@/projects/icon-cache"; -import { nativePerformanceTrace } from "@/performance/native-trace"; +import { createPerformanceTrace } from "@/performance/native-trace"; import { revokePushNotifications } from "@/push-notifications"; import { createAppWebSocketFactory } from "./websocket-factory"; @@ -544,7 +545,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, capabilities: appCapabilities, - trace: nativePerformanceTrace, + trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), providerSnapshots: "wire", ...(host.password ? { password: host.password } : {}), localCredential: () => readDesktopManagedLocalCredential(connection), @@ -604,7 +605,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { localCredential: () => readDesktopManagedLocalCredential(connection), ...(timeoutMs !== undefined ? { timeoutMs } : {}), capabilities: appCapabilities, - trace: nativePerformanceTrace, + trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), }), getClientId: () => getOrCreateClientId(), mountClientHandlers: ({ client, host }) => { @@ -1159,12 +1160,18 @@ export class HostRuntimeController { }); } - private logConnectionTransition(_input: { + private logConnectionTransition(input: { from: HostRuntimeConnectionMachineState["tag"]; to: HostRuntimeConnectionMachineState["tag"]; event: HostRuntimeConnectionMachineEvent; }): void { - // Intentionally empty - logging removed. + diagnostics?.record("host.transition", { + host: this.host.serverId, + from: input.from, + to: input.to, + event: input.event.type, + epoch: String(this.connectionEpoch), + }); } private trackConnectionFirstSeen(): void { diff --git a/packages/app/src/utils/render-profiler.tsx b/packages/app/src/utils/render-profiler.tsx index cefecffde2..4a93e7d78c 100644 --- a/packages/app/src/utils/render-profiler.tsx +++ b/packages/app/src/utils/render-profiler.tsx @@ -1,3 +1,4 @@ +import { diagnostics } from "@/diagnostics"; import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from "react"; export interface RenderProfileSample { @@ -30,7 +31,9 @@ function getSearchParam(name: string): string | null { export function isRenderProfileEnabled(): boolean { return ( - globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true || getSearchParam("renderProfile") === "1" + diagnostics !== null || + globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true || + getSearchParam("renderProfile") === "1" ); } @@ -42,7 +45,13 @@ const onRender: ProfilerOnRenderCallback = ( startTime, commitTime, ) => { + if (diagnostics) { + diagnostics.render({ id, phase, actualDuration, startTime, commitTime }); + return; + } globalThis.__PASEO_RENDER_PROFILE__ ??= []; + if (globalThis.__PASEO_RENDER_PROFILE__.length >= 2000) + globalThis.__PASEO_RENDER_PROFILE__.shift(); globalThis.__PASEO_RENDER_PROFILE__.push({ id, phase, @@ -73,7 +82,7 @@ export function RenderProfile({ id, children }: { id: string; children: ReactNod } export function recordRenderProfileReasons(id: string, reasons: string[]) { - if (!isRenderProfileEnabled() || reasons.length === 0) { + if (diagnostics || !isRenderProfileEnabled() || reasons.length === 0) { return; } globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {}; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 7469470ad1..1f49a74eea 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -200,38 +200,68 @@ test("traces WebSocket frames, message types, and JSON parse duration", async () mock.triggerOpen({ preserveSent: true }); await connectPromise; - expect(recorder.records).toEqual([ - { - phase: "begin", - name: "paseo.ws.message.outbound", - args: { envelopeType: "hello", messageType: "hello" }, - }, - { phase: "end" }, - { - phase: "begin", - name: "paseo.ws.frame.outbound", - args: { kind: "text", size: expect.any(String) }, - }, - { phase: "end" }, - { - phase: "begin", - name: "paseo.ws.frame.inbound", - args: { kind: "text", size: expect.any(String) }, - }, - { - phase: "begin", - name: "paseo.ws.json.parse", - args: { size: expect.any(String) }, - }, - { phase: "end" }, - { - phase: "begin", - name: "paseo.ws.message.inbound", - args: { envelopeType: "session", messageType: "status" }, - }, - { phase: "end" }, - { phase: "end" }, - ]); + const response = client.fetchAgents({ requestId: "diagnostic-request", scope: "active" }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fetch_agents_response", + payload: { + requestId: "diagnostic-request", + entries: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }, + }), + ); + await response; + const spans = recorder.records.filter((record) => record.phase === "begin"); + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "paseo.ws.frame.inbound", + args: { + kind: "text", + size: expect.any(String), + sizeUnit: "utf16-code-units", + connectionAttempt: "1", + }, + }), + expect.objectContaining({ name: "paseo.ws.json.parse" }), + expect.objectContaining({ name: "paseo.ws.validate" }), + expect.objectContaining({ + name: "paseo.ws.message.outbound", + args: { + envelopeType: "session", + messageType: "fetch_agents_request", + requestId: "diagnostic-request", + connectionAttempt: "1", + }, + }), + expect.objectContaining({ + name: "paseo.ws.message.inbound", + args: { + envelopeType: "session", + messageType: "fetch_agents_response", + requestId: "diagnostic-request", + connectionAttempt: "1", + }, + }), + expect.objectContaining({ + name: "paseo.ws.dispatch", + args: { + messageType: "fetch_agents_response", + requestId: "diagnostic-request", + connectionAttempt: "1", + }, + }), + ]), + ); + // Every started span must close, including nested send/dispatch work. + let depth = 0; + for (const record of recorder.records) { + depth += record.phase === "begin" ? 1 : -1; + expect(depth).toBeGreaterThanOrEqual(0); + } + expect(depth).toBe(0); + expect(JSON.stringify(recorder.records)).not.toContain("trace_unit_test"); }); test("does not infer browser automation capabilities from Electron runtime", async () => { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 647e1fddf6..4f20ae559d 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1147,19 +1147,34 @@ function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): voi throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES); } -function describeInboundTransportFrame( - frame: unknown, - rawBytes: Uint8Array | null, -): Record { +function describeInboundTransportFrame(frame: unknown): Record { if (typeof frame === "string") { - return { kind: "text", size: String(frame.length) }; + return { kind: "text", size: String(frame.length), sizeUnit: "utf16-code-units" }; } - if (rawBytes) { - return { kind: "binary", size: String(rawBytes.byteLength) }; + if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) { + return { kind: "binary", size: String(frame.byteLength), sizeUnit: "bytes" }; } return { kind: "unknown", size: "0" }; } +function describeRequestIdentity(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + const message = "message" in value ? value.message : value; + if (!message || typeof message !== "object") return {}; + if ("requestId" in message && typeof message.requestId === "string") + return { requestId: message.requestId }; + if ( + "payload" in message && + message.payload && + typeof message.payload === "object" && + "requestId" in message.payload && + typeof message.payload.requestId === "string" + ) { + return { requestId: message.payload.requestId }; + } + return {}; +} + function hashForLog(value: string): string { let hash = 0; for (let index = 0; index < value.length; index += 1) { @@ -1277,6 +1292,7 @@ export class DaemonClient { private connectTimeout: ReturnType | null = null; private pendingGenericTransportErrorTimeout: ReturnType | null = null; private reconnectAttempt = 0; + private traceConnectionAttempt = 0; private shouldReconnect = true; private connectPromise: Promise | null = null; private connectResolve: (() => void) | null = null; @@ -1429,6 +1445,7 @@ export class DaemonClient { headers: selected.headers, ...(selected.protocols ? { protocols: selected.protocols } : {}), }); + this.traceConnectionAttempt += 1; this.transport = transport; this.lastServerInfoMessage = null; this.authFailureReasonValue = null; @@ -1457,6 +1474,7 @@ export class DaemonClient { this.transportCleanup = [ transport.onOpen(() => { + this.traceInstant("paseo.connection.open"); if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; @@ -1465,6 +1483,11 @@ export class DaemonClient { void this.sendHelloMessage(); }), transport.onClose((event) => { + this.traceInstant("paseo.connection.close", { + code: String( + event && typeof event === "object" && "code" in event ? event.code : "unknown", + ), + }); this.resetConnectTimeout(); if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); @@ -1484,6 +1507,7 @@ export class DaemonClient { }); }), transport.onError((event) => { + this.traceInstant("paseo.connection.error"); this.resetConnectTimeout(); const reason = describeTransportError(event); const isGeneric = reason === "Transport error"; @@ -1755,7 +1779,7 @@ export class DaemonClient { if (!trace?.isEnabled()) { return false; } - trace.beginSection(name, args); + trace.beginSection(name, { ...args, connectionAttempt: String(this.traceConnectionAttempt) }); return true; } @@ -1771,11 +1795,16 @@ export class DaemonClient { } private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void { - this.traceInstant("paseo.ws.message.outbound", { + const isOpen = this.beginTraceSection("paseo.ws.message.outbound", { envelopeType, messageType, + ...describeRequestIdentity(message), }); - this.sendTransportFrame(JSON.stringify(message)); + try { + this.sendTransportFrame(JSON.stringify(message)); + } finally { + this.endTraceSection(isOpen); + } } private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void { @@ -1787,6 +1816,7 @@ export class DaemonClient { const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", { kind: typeof frame === "string" ? "text" : "binary", size: String(getTransportFrameSize(frame)), + sizeUnit: typeof frame === "string" ? "utf16-code-units" : "bytes", }); try { this.transport.send(frame); @@ -6392,12 +6422,13 @@ export class DaemonClient { return; } - const rawBytes = asUint8Array(rawData); const isOpen = this.beginTraceSection( "paseo.ws.frame.inbound", - describeInboundTransportFrame(rawData, rawBytes), + describeInboundTransportFrame(rawData), ); try { + // Record arrival before conversion: encoding a large text frame is JS work too. + const rawBytes = asUint8Array(rawData); if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) { return; } @@ -6417,17 +6448,26 @@ export class DaemonClient { let parsedJson: unknown; const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", { size: String(bytes), + sizeUnit: rawBytesLength === undefined ? "utf16-code-units" : "bytes", }); try { parsedJson = JSON.parse(payload); } catch { + this.traceInstant("paseo.ws.json.invalid"); return; } finally { this.endTraceSection(parseTraceOpen); } - const parsed = validateWSOutboundMessage(parsedJson); + const validationTrace = this.beginTraceSection("paseo.ws.validate"); + let parsed: ReturnType; + try { + parsed = validateWSOutboundMessage(parsedJson); + } finally { + this.endTraceSection(validationTrace); + } if (!parsed.success) { + this.traceInstant("paseo.ws.validation.failed"); const responseIdentity = extractCorrelatedResponseIdentity(parsedJson); const envelopeType = parsedJson != null && @@ -6460,6 +6500,7 @@ export class DaemonClient { } if (parsed.data.type === "hello.rejected") { + this.traceInstant("paseo.connection.rejected", { reason: parsed.data.reason }); const reasonMessage = { password_required: "Password required", incorrect_password: "Incorrect password", @@ -6474,8 +6515,17 @@ export class DaemonClient { this.traceInstant("paseo.ws.message.inbound", { envelopeType: "session", messageType: parsed.data.message.type, + ...describeRequestIdentity(parsed.data.message), }); - this.handleSessionMessage(parsed.data.message); + const dispatchTrace = this.beginTraceSection("paseo.ws.dispatch", { + messageType: parsed.data.message.type, + ...describeRequestIdentity(parsed.data.message), + }); + try { + this.handleSessionMessage(parsed.data.message); + } finally { + this.endTraceSection(dispatchTrace); + } const msgType = parsed.data.message.type; this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs); if (parsed.data.message.type === "agent_stream") { @@ -6490,6 +6540,7 @@ export class DaemonClient { envelopeType: "binary", messageType: "file", opcode: String(fileFrame.opcode), + requestId: fileFrame.requestId, }); this.consecutiveLivenessFailures = 0; this.handleFileTransferFrame(fileFrame); @@ -6505,6 +6556,7 @@ export class DaemonClient { envelopeType: "binary", messageType: "terminal", opcode: String(frame.opcode), + slot: String(frame.slot), }); this.consecutiveLivenessFailures = 0; const binaryStartMs = perfNow(); @@ -6623,6 +6675,12 @@ export class DaemonClient { next.status === "disconnected" && typeof next.reason === "string" ? next.reason : null; const reason = metadata?.reason ?? reasonFromNext; const reasonCode = metadata?.reasonCode ?? toReasonCode(reason); + this.traceInstant("paseo.connection.state", { + from: previous.status, + to: next.status, + event: metadata?.event ?? "STATE_UPDATE", + reasonCode: reasonCode ?? "none", + }); this.logger.debug( { serverId: this.logServerId, @@ -6722,6 +6780,10 @@ export class DaemonClient { const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; const delay = Math.min(baseDelay * 2 ** attempt, maxDelay); + this.traceInstant("paseo.connection.reconnect", { + attempt: String(attempt), + delayMs: String(delay), + }); this.reconnectAttempt = attempt + 1; this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null; diff --git a/scripts/build-apk.mjs b/scripts/build-apk.mjs new file mode 100644 index 0000000000..964e6eacf2 --- /dev/null +++ b/scripts/build-apk.mjs @@ -0,0 +1,52 @@ +import { spawnSync, execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, rmSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const target = process.argv[2] ?? "universal"; +const architectures = { + universal: "armeabi-v7a,arm64-v8a,x86,x86_64", + arm64: "arm64-v8a", +}[target]; +if (!architectures) { + throw new Error(`Unknown APK target: ${target}. Use universal or arm64.`); +} + +const root = fileURLToPath(new URL("..", import.meta.url)); +const app = path.join(root, "packages/app"); +const env = { + ...process.env, + APP_VARIANT: "production", + PASEO_WATCHDOG_BUILD: "1", + PASEO_DIAGNOSTIC_BUILD_TIME: new Date().toISOString(), + PASEO_DIAGNOSTIC_REVISION: + execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" }).trim() + + (execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim() + ? "-dirty" + : ""), +}; +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, env, stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} +run("npm", ["run", "build:app-deps"], root); +run("npx", ["expo", "prebuild", "--platform", "android", "--no-install"], app); +// Gradle does not track Metro's environment flags. Always regenerate the JS artifact +// so a previous ordinary release cannot silently bypass the profiling renderer. +rmSync(path.join(app, "android/app/build/generated/assets"), { recursive: true, force: true }); +run( + "./gradlew", + [ + "assembleRelease", + "--no-daemon", + "--max-workers=2", + "-Dorg.gradle.parallel=false", + `-PreactNativeArchitectures=${architectures}`, + ], + path.join(app, "android"), +); +const destination = path.join(root, `artifacts/paseo-dogfood-${target}.apk`); +mkdirSync(path.dirname(destination), { recursive: true }); +copyFileSync(path.join(app, "android/app/build/outputs/apk/release/app-release.apk"), destination); +console.log(`\nDiagnostic release APK: ${destination}\nLogs: Downloads/Paseo diagnostics\n`);