mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
refactor(app): own watchdog sinks and bounded log storage
This commit is contained in:
@@ -417,6 +417,14 @@ jobs:
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Run Android module tests
|
||||
run: npm run test:android --workspace=@getpaseo/app
|
||||
|
||||
playwright-1:
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
|
||||
@@ -90,8 +90,6 @@ 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
|
||||
@@ -108,6 +106,8 @@ npm run format:check # Check formatting without writing
|
||||
|
||||
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
|
||||
|
||||
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.
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Release branches
|
||||
|
||||
+10
-4
@@ -73,13 +73,17 @@ npm run dev:ios # Build/install the iOS development app
|
||||
`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.
|
||||
flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for
|
||||
personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag;
|
||||
watchdog recording does not send duplicate spans to that sink by default. 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.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
@@ -91,13 +95,15 @@ 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
|
||||
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,
|
||||
`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event
|
||||
when JS resumes before the next native sampling tick. 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
|
||||
|
||||
@@ -11,7 +11,7 @@ const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
|
||||
const isProfileBuild = isWatchdogBuild || process.env.PASEO_PROFILE_BUILD === "1";
|
||||
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
|
||||
|
||||
const buildProfile = isFdroidBuild
|
||||
? {
|
||||
@@ -181,7 +181,7 @@ export default {
|
||||
},
|
||||
],
|
||||
...buildProfile.fdroidPlugins,
|
||||
...(isProfileBuild ? [withAndroidProfileable] : []),
|
||||
...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
|
||||
@@ -9,8 +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 { startWatchdog } from "./src/watchdog";
|
||||
import { RootApp } from "./src/root-app";
|
||||
|
||||
startDiagnostics();
|
||||
startWatchdog();
|
||||
renderRootComponent(RootApp);
|
||||
|
||||
@@ -5,6 +5,7 @@ const path = require("path");
|
||||
const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const fdroidModuleOverrides = {
|
||||
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
|
||||
@@ -16,7 +17,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
.toLowerCase();
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
config.cacheVersion = process.env.PASEO_WATCHDOG_BUILD === "1" ? "watchdog" : "standard";
|
||||
config.cacheVersion = isWatchdogBuild ? "watchdog" : "standard";
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
|
||||
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
|
||||
@@ -77,7 +78,7 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (
|
||||
process.env.PASEO_WATCHDOG_BUILD === "1" &&
|
||||
isWatchdogBuild &&
|
||||
platform === "android" &&
|
||||
/[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) &&
|
||||
/implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName)
|
||||
|
||||
+27
-79
@@ -1,12 +1,9 @@
|
||||
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
|
||||
@@ -20,16 +17,20 @@ internal class DiagnosticRecorder(private val context: Context, private val meta
|
||||
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)
|
||||
|
||||
private val log = RotatingLog(DownloadsLogStorage(context, session), { part ->
|
||||
serialize(Event("file.start", metadata + mapOf("schema" to "1", "part" to "$part")))
|
||||
}, { error ->
|
||||
failed = true
|
||||
queue.clear()
|
||||
Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error)
|
||||
})
|
||||
|
||||
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)
|
||||
record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "${HeartbeatTiming.intervalMs}", "stallThresholdMs" to "${HeartbeatTiming.overdueMs}"))
|
||||
worker.scheduleWithFixedDelay({ drain() }, 0, HeartbeatTiming.intervalMs, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
fun record(name: String, data: Map<String, String>) {
|
||||
@@ -44,85 +45,32 @@ internal class DiagnosticRecorder(private val context: Context, private val meta
|
||||
|
||||
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)
|
||||
watchdog.check(SystemClock.elapsedRealtime())
|
||||
val lost = dropped.getAndSet(0)
|
||||
if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost")))
|
||||
var remaining = 2048
|
||||
while (remaining-- > 0 && !failed) {
|
||||
val event = queue.poll() ?: break
|
||||
write(event)
|
||||
}
|
||||
log.finishBatch()
|
||||
}
|
||||
|
||||
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 write(event: Event) = log.write(serialize(event))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun serialize(event: Event): ByteArray = (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)
|
||||
|
||||
fun close() {
|
||||
worker.execute {
|
||||
record("session.end", emptyMap())
|
||||
drain()
|
||||
runCatching { output?.close() }
|
||||
output = null
|
||||
log.finishBatch()
|
||||
}
|
||||
worker.shutdown()
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import java.io.OutputStream
|
||||
|
||||
internal class DownloadsLogStorage(private val context: Context, private val session: String) : LogStorage {
|
||||
private val resolver = context.contentResolver
|
||||
private val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||
private val directory = "Download/Paseo diagnostics/"
|
||||
|
||||
override fun create(part: Int): String {
|
||||
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, directory)
|
||||
}
|
||||
return checkNotNull(resolver.insert(collection, values)).toString()
|
||||
}
|
||||
|
||||
override fun append(id: String): OutputStream =
|
||||
checkNotNull(resolver.openOutputStream(Uri.parse(id), "wa")).buffered(64 * 1024)
|
||||
|
||||
override fun newest(): List<String> = buildList {
|
||||
resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID),
|
||||
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?",
|
||||
arrayOf(directory, context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor ->
|
||||
while (cursor.moveToNext()) add(ContentUris.withAppendedId(collection, cursor.getLong(0)).toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(id: String) { resolver.delete(Uri.parse(id), null, null) }
|
||||
}
|
||||
+9
-3
@@ -1,5 +1,11 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
internal object HeartbeatTiming {
|
||||
const val intervalMs = 250L
|
||||
const val overdueMs = 500L
|
||||
const val gapMs = intervalMs + overdueMs
|
||||
}
|
||||
|
||||
/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */
|
||||
internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map<String, String>) -> Unit) {
|
||||
private var foreground = true
|
||||
@@ -7,8 +13,8 @@ internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map<S
|
||||
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}"))
|
||||
if (foreground && now - lastHeartbeat > HeartbeatTiming.gapMs) {
|
||||
emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - HeartbeatTiming.intervalMs}"))
|
||||
}
|
||||
lastHeartbeat = now
|
||||
overdue = false
|
||||
@@ -24,7 +30,7 @@ internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map<S
|
||||
|
||||
@Synchronized fun check(now: Long) {
|
||||
val gap = now - lastHeartbeat
|
||||
if (foreground && gap > 750 && !overdue) {
|
||||
if (foreground && gap > HeartbeatTiming.gapMs && !overdue) {
|
||||
emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat"))
|
||||
overdue = true
|
||||
}
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ class PaseoNativeTraceModule : Module() {
|
||||
if (recorder == null) {
|
||||
appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) }
|
||||
}
|
||||
HeartbeatTiming.intervalMs
|
||||
}
|
||||
Function("record") { name: String, data: Map<String, String> -> recorder?.record(name, data) }
|
||||
Function("heartbeat") { recorder?.heartbeat() }
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import java.io.OutputStream
|
||||
|
||||
/** Storage supplies installation-owned files, newest first. */
|
||||
internal interface LogStorage {
|
||||
fun create(part: Int): String
|
||||
fun append(id: String): OutputStream
|
||||
fun newest(): List<String>
|
||||
fun delete(id: String)
|
||||
}
|
||||
|
||||
/** Owns file identity, bounded retention and the terminal storage-failure state. */
|
||||
internal class RotatingLog(
|
||||
private val storage: LogStorage,
|
||||
private val header: (Int) -> ByteArray,
|
||||
private val onFailure: (Exception) -> Unit,
|
||||
private val maxBytes: Int = 5 * 1024 * 1024,
|
||||
) {
|
||||
private var current: String? = null
|
||||
private var output: OutputStream? = null
|
||||
private var size = 0
|
||||
private var part = 0
|
||||
var failed = false
|
||||
private set
|
||||
|
||||
fun write(bytes: ByteArray) = attempt {
|
||||
if (current == null || size + bytes.size > maxBytes) {
|
||||
finishBatch()
|
||||
if (failed) return@attempt
|
||||
val nextPart = part++
|
||||
current = storage.create(nextPart)
|
||||
output = storage.append(checkNotNull(current))
|
||||
val first = header(nextPart)
|
||||
output!!.write(first)
|
||||
size = first.size
|
||||
storage.newest().drop(8).forEach(storage::delete)
|
||||
}
|
||||
if (output == null) output = storage.append(checkNotNull(current))
|
||||
output!!.write(bytes)
|
||||
size += bytes.size
|
||||
}
|
||||
|
||||
// Closing publishes size and modification time in Android Files.
|
||||
fun finishBatch() = attempt {
|
||||
output?.close()
|
||||
output = null
|
||||
}
|
||||
|
||||
private fun attempt(action: () -> Unit) {
|
||||
if (failed) return
|
||||
try {
|
||||
action()
|
||||
} catch (error: Exception) {
|
||||
failed = true
|
||||
runCatching { output?.close() }
|
||||
output = null
|
||||
onFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class RotatingLogTest {
|
||||
private class MemoryStorage : LogStorage {
|
||||
val files = linkedMapOf<String, ByteArrayOutputStream>()
|
||||
var fail = false
|
||||
var opens = 0
|
||||
override fun create(part: Int): String {
|
||||
val id = "part-$part"
|
||||
files[id] = ByteArrayOutputStream()
|
||||
return id
|
||||
}
|
||||
override fun append(id: String): OutputStream {
|
||||
opens++
|
||||
if (fail) throw IOException("disk full")
|
||||
return files.getValue(id)
|
||||
}
|
||||
override fun newest() = files.keys.reversed()
|
||||
override fun delete(id: String) { files.remove(id) }
|
||||
}
|
||||
|
||||
@Test fun rotatesAtByteLimitAndKeepsEightIncludingCurrent() {
|
||||
val storage = MemoryStorage()
|
||||
val log = RotatingLog(storage, { "header:$it\n".toByteArray() }, { throw it }, 20)
|
||||
repeat(10) { log.write("123456789012".toByteArray()); log.finishBatch() }
|
||||
assertEquals((2..9).map { "part-$it" }, storage.files.keys.toList())
|
||||
for (part in 2..9) assertEquals("header:$part\n123456789012", storage.files.getValue("part-$part").toString("UTF-8"))
|
||||
assertFalse(log.failed)
|
||||
}
|
||||
|
||||
@Test fun reopeningBatchAppendsWithoutRepeatingHeaderAndExactLimitFits() {
|
||||
val storage = MemoryStorage()
|
||||
val log = RotatingLog(storage, { "H".toByteArray() }, { throw it }, 5)
|
||||
log.write("12".toByteArray())
|
||||
log.finishBatch()
|
||||
log.write("34".toByteArray())
|
||||
assertEquals(listOf("part-0"), storage.files.keys.toList())
|
||||
assertEquals("H1234", storage.files.getValue("part-0").toString("UTF-8"))
|
||||
log.write("5".toByteArray())
|
||||
assertEquals("H5", storage.files.getValue("part-1").toString("UTF-8"))
|
||||
}
|
||||
|
||||
@Test fun storageFailureStopsRecordingWithoutRetryStorm() {
|
||||
val storage = MemoryStorage()
|
||||
var failures = 0
|
||||
val log = RotatingLog(storage, { byteArrayOf() }, { failures++ })
|
||||
storage.fail = true
|
||||
repeat(100) { log.write(byteArrayOf(1)); log.finishBatch() }
|
||||
assertTrue(log.failed)
|
||||
assertEquals(1, failures)
|
||||
assertEquals(1, storage.opens)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,8 @@
|
||||
"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"
|
||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { diagnostics } from "@/diagnostics";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client";
|
||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||
import { isProfileBuild } from "@/constants/build-profile";
|
||||
@@ -10,17 +10,14 @@ interface PaseoNativeTraceModule {
|
||||
|
||||
const traceModule = requireOptionalNativeModule<PaseoNativeTraceModule>("PaseoNativeTrace");
|
||||
|
||||
export const nativePerformanceTrace: DaemonClientTrace = {
|
||||
isEnabled() {
|
||||
return isProfileBuild && traceModule !== null;
|
||||
},
|
||||
beginSection(name, args) {
|
||||
traceModule?.beginSection(formatSectionName(name, args));
|
||||
},
|
||||
endSection() {
|
||||
traceModule?.endSection();
|
||||
},
|
||||
};
|
||||
const nativeSink: DaemonClientTrace | null =
|
||||
isProfileBuild && traceModule
|
||||
? {
|
||||
isEnabled: () => true,
|
||||
beginSection: (name, args) => traceModule.beginSection(formatSectionName(name, args)),
|
||||
endSection: () => traceModule.endSection(),
|
||||
}
|
||||
: null;
|
||||
|
||||
function formatSectionName(name: string, args?: Record<string, string>): string {
|
||||
if (!args) {
|
||||
@@ -30,27 +27,29 @@ function formatSectionName(name: string, args?: Record<string, string>): string
|
||||
return `${name}|${fields.join(";")}`;
|
||||
}
|
||||
|
||||
const instantSinks: ((name: string, args?: Record<string, string>) => void)[] = [];
|
||||
if (watchdog) instantSinks.push(watchdog.record);
|
||||
if (nativeSink)
|
||||
instantSinks.push((name, args) => {
|
||||
nativeSink.beginSection(name, args);
|
||||
nativeSink.endSection();
|
||||
});
|
||||
export function traceInstant(name: string, args?: Record<string, string>): void {
|
||||
diagnostics?.record(name, args);
|
||||
if (!nativePerformanceTrace.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
nativePerformanceTrace.beginSection(name, args);
|
||||
nativePerformanceTrace.endSection();
|
||||
for (const write of instantSinks) write(name, args);
|
||||
}
|
||||
|
||||
/** Each client owns its section stack; overlapping hosts never share span identity. */
|
||||
export function createPerformanceTrace(context: Record<string, string>): DaemonClientTrace {
|
||||
const recorder = diagnostics?.trace(context);
|
||||
const sinks = [watchdog?.trace(context), nativeSink].filter(
|
||||
(sink): sink is DaemonClientTrace => sink != null,
|
||||
);
|
||||
return {
|
||||
isEnabled: () => recorder !== undefined || nativePerformanceTrace.isEnabled(),
|
||||
isEnabled: () => sinks.length > 0,
|
||||
beginSection(name, args) {
|
||||
recorder?.beginSection(name, args);
|
||||
if (nativePerformanceTrace.isEnabled()) nativePerformanceTrace.beginSection(name, args);
|
||||
for (const sink of sinks) sink.beginSection(name, args);
|
||||
},
|
||||
endSection() {
|
||||
recorder?.endSection();
|
||||
if (nativePerformanceTrace.isEnabled()) nativePerformanceTrace.endSection();
|
||||
for (const sink of sinks) sink.endSection();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { diagnostics } from "@/diagnostics";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import { useSyncExternalStore, useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
@@ -1165,7 +1165,7 @@ export class HostRuntimeController {
|
||||
to: HostRuntimeConnectionMachineState["tag"];
|
||||
event: HostRuntimeConnectionMachineEvent;
|
||||
}): void {
|
||||
diagnostics?.record("host.transition", {
|
||||
watchdog?.record("host.transition", {
|
||||
host: this.host.serverId,
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { diagnostics } from "@/diagnostics";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from "react";
|
||||
|
||||
export interface RenderProfileSample {
|
||||
@@ -29,14 +29,41 @@ function getSearchParam(name: string): string | null {
|
||||
return new URLSearchParams(search).get(name);
|
||||
}
|
||||
|
||||
export function isRenderProfileEnabled(): boolean {
|
||||
return (
|
||||
diagnostics !== null ||
|
||||
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true ||
|
||||
getSearchParam("renderProfile") === "1"
|
||||
);
|
||||
interface RenderSink {
|
||||
isEnabled(): boolean;
|
||||
render(sample: RenderProfileSample): void;
|
||||
reasons(id: string, reasons: string[]): void;
|
||||
}
|
||||
|
||||
function createBrowserSink(): RenderSink {
|
||||
return {
|
||||
isEnabled: () =>
|
||||
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true ||
|
||||
getSearchParam("renderProfile") === "1",
|
||||
render(sample) {
|
||||
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
|
||||
globalThis.__PASEO_RESET_RENDER_PROFILE__ ??= () => {
|
||||
globalThis.__PASEO_RENDER_PROFILE__ = [];
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
|
||||
};
|
||||
if (globalThis.__PASEO_RENDER_PROFILE__.length >= 2000)
|
||||
globalThis.__PASEO_RENDER_PROFILE__.shift();
|
||||
globalThis.__PASEO_RENDER_PROFILE__.push(sample);
|
||||
},
|
||||
reasons(id, reasons) {
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
|
||||
const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {});
|
||||
for (const reason of reasons) counts[reason] = (counts[reason] ?? 0) + 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Choose the destination once. Browser QA can still enable capture at runtime.
|
||||
const sink: RenderSink = watchdog
|
||||
? { isEnabled: () => true, render: watchdog.render, reasons: () => {} }
|
||||
: createBrowserSink();
|
||||
|
||||
export const isRenderProfileEnabled = sink.isEnabled;
|
||||
const onRender: ProfilerOnRenderCallback = (
|
||||
id,
|
||||
phase,
|
||||
@@ -44,36 +71,10 @@ const onRender: ProfilerOnRenderCallback = (
|
||||
baseDuration,
|
||||
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,
|
||||
actualDuration,
|
||||
baseDuration,
|
||||
startTime,
|
||||
commitTime,
|
||||
});
|
||||
};
|
||||
) => sink.render({ id, phase, actualDuration, baseDuration, startTime, commitTime });
|
||||
|
||||
export function RenderProfile({ id, children }: { id: string; children: ReactNode }) {
|
||||
if (!isRenderProfileEnabled()) {
|
||||
return children;
|
||||
}
|
||||
|
||||
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
|
||||
globalThis.__PASEO_RESET_RENDER_PROFILE__ = () => {
|
||||
globalThis.__PASEO_RENDER_PROFILE__ = [];
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
|
||||
};
|
||||
|
||||
if (!sink.isEnabled()) return children;
|
||||
return (
|
||||
<Profiler id={id} onRender={onRender}>
|
||||
{children}
|
||||
@@ -82,12 +83,5 @@ export function RenderProfile({ id, children }: { id: string; children: ReactNod
|
||||
}
|
||||
|
||||
export function recordRenderProfileReasons(id: string, reasons: string[]) {
|
||||
if (diagnostics || !isRenderProfileEnabled() || reasons.length === 0) {
|
||||
return;
|
||||
}
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
|
||||
const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {});
|
||||
for (const reason of reasons) {
|
||||
counts[reason] = (counts[reason] ?? 0) + 1;
|
||||
}
|
||||
if (sink.isEnabled()) sink.reasons(id, reasons);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { createRecorder } from "./internal/recorder";
|
||||
|
||||
interface NativeRecorder {
|
||||
startRecorder(metadata: Record<string, string>): void;
|
||||
startRecorder(metadata: Record<string, string>): number;
|
||||
record(name: string, fields: Record<string, string>): void;
|
||||
heartbeat(): void;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const native =
|
||||
Constants.expoConfig?.extra?.watchdogBuild === true
|
||||
? requireOptionalNativeModule<NativeRecorder>("PaseoNativeTrace")
|
||||
: null;
|
||||
export const diagnostics = native
|
||||
export const watchdog = native
|
||||
? createRecorder(
|
||||
(name, fields) => native.record(name, fields),
|
||||
() => performance.now(),
|
||||
@@ -21,15 +21,15 @@ export const diagnostics = native
|
||||
: null;
|
||||
let started = false;
|
||||
|
||||
export function startDiagnostics(): void {
|
||||
if (!native || !diagnostics || started) return;
|
||||
export function startWatchdog(): void {
|
||||
if (!native || !watchdog || started) return;
|
||||
started = true;
|
||||
native.startRecorder({
|
||||
const heartbeatMs = 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);
|
||||
setInterval(() => native.heartbeat(), heartbeatMs);
|
||||
setInterval(() => watchdog.flushRenders(), 1000);
|
||||
}
|
||||
@@ -254,13 +254,20 @@ test("traces WebSocket frames, message types, and JSON parse duration", async ()
|
||||
}),
|
||||
]),
|
||||
);
|
||||
// Every started span must close, including nested send/dispatch work.
|
||||
let depth = 0;
|
||||
const stack: string[] = [];
|
||||
const parentage: [string, string | undefined][] = [];
|
||||
for (const record of recorder.records) {
|
||||
depth += record.phase === "begin" ? 1 : -1;
|
||||
expect(depth).toBeGreaterThanOrEqual(0);
|
||||
if (record.phase === "begin") {
|
||||
parentage.push([record.name, stack.at(-1)]);
|
||||
stack.push(record.name);
|
||||
} else {
|
||||
expect(stack.length).toBeGreaterThan(0);
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
expect(depth).toBe(0);
|
||||
expect(stack).toEqual([]);
|
||||
expect(parentage).toContainEqual(["paseo.ws.frame.outbound", "paseo.ws.message.outbound"]);
|
||||
expect(parentage).toContainEqual(["paseo.ws.dispatch", "paseo.ws.frame.inbound"]);
|
||||
expect(JSON.stringify(recorder.records)).not.toContain("trace_unit_test");
|
||||
});
|
||||
|
||||
|
||||
@@ -1794,12 +1794,21 @@ export class DaemonClient {
|
||||
this.endTraceSection(isOpen);
|
||||
}
|
||||
|
||||
private beginSessionDispatchTrace(message: SessionOutboundMessage): boolean {
|
||||
if (!this.config.trace?.isEnabled()) return false;
|
||||
const fields = { messageType: message.type, ...describeRequestIdentity(message) };
|
||||
this.traceInstant("paseo.ws.message.inbound", { envelopeType: "session", ...fields });
|
||||
return this.beginTraceSection("paseo.ws.dispatch", fields);
|
||||
}
|
||||
|
||||
private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void {
|
||||
const isOpen = this.beginTraceSection("paseo.ws.message.outbound", {
|
||||
envelopeType,
|
||||
messageType,
|
||||
...describeRequestIdentity(message),
|
||||
});
|
||||
const isOpen =
|
||||
this.config.trace?.isEnabled() === true &&
|
||||
this.beginTraceSection("paseo.ws.message.outbound", {
|
||||
envelopeType,
|
||||
messageType,
|
||||
...describeRequestIdentity(message),
|
||||
});
|
||||
try {
|
||||
this.sendTransportFrame(JSON.stringify(message));
|
||||
} finally {
|
||||
@@ -6512,15 +6521,7 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
this.traceInstant("paseo.ws.message.inbound", {
|
||||
envelopeType: "session",
|
||||
messageType: parsed.data.message.type,
|
||||
...describeRequestIdentity(parsed.data.message),
|
||||
});
|
||||
const dispatchTrace = this.beginTraceSection("paseo.ws.dispatch", {
|
||||
messageType: parsed.data.message.type,
|
||||
...describeRequestIdentity(parsed.data.message),
|
||||
});
|
||||
const dispatchTrace = this.beginSessionDispatchTrace(parsed.data.message);
|
||||
try {
|
||||
this.handleSessionMessage(parsed.data.message);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user