Native rooted Android app for block-I/O tracing - #1
Conversation
Add an Android app module (app/) that runs the block-I/O collector on-device with a Jetpack Compose Start/Stop UI and a foreground service, instead of driving the Python CLI over adb. It emits the same CSV schema as the Python and Linux tracers (only the container differs: .csv.gz vs .csv.zst). Engine: - RootShell: su exec + streaming - FtraceControl: enable/stream/teardown tracefs (block_rq_issue/complete, mono clock) - FtraceParser + BlockPairer: 1:1 Kotlin ports of parsers.py / BlockPairer, recovering device latency by pairing issue/complete on (device, sector) - ProcSnapper (ps) + SystemSnapper (getprop+/proc) - TraceWriter: gzip CSV streams + manifest.json (machine id, clock offset) - TracerService (foreground) + Compose MainActivity with live counters Schema.kt mirrors schema.py (SCHEMA_VERSION 3) and must stay in sync; JVM unit tests in app/src/test mirror tests/test_parsers.py. Adds a GitHub Actions workflow that runs the unit tests and assembles the debug APK, plus docs/ANDROID_APP.md. The Python CLI remains the reference collector/host driver. Note: requires root (su) for block tracing; falls back to snapshot-only without it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbHpkJ1MdVfGz9bi6Ekb1i
There was a problem hiding this comment.
Code Review
This pull request introduces a native Android application for rooted block-I/O tracing, featuring a Compose-based UI, a foreground service, ftrace streaming and parsing, and periodic system/process snapshots. The review feedback highlights several critical improvements for thread safety, performance, and code quality. Specifically, it suggests using MutableStateFlow.update to ensure atomic state updates, reusing SimpleDateFormat via ThreadLocal and removing redundant body.trim() allocations to reduce garbage collection pressure in high-frequency tracing paths, buffering GZIPOutputStream to optimize disk writes, and replacing the unidiomatic use of Any with nullable types in BlockPairer for better type safety.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| import kotlinx.coroutines.flow.MutableStateFlow | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.asStateFlow |
There was a problem hiding this comment.
Add the kotlinx.coroutines.flow.update import to support atomic updates on MutableStateFlow.
| import kotlinx.coroutines.flow.MutableStateFlow | |
| import kotlinx.coroutines.flow.StateFlow | |
| import kotlinx.coroutines.flow.asStateFlow | |
| import kotlinx.coroutines.flow.MutableStateFlow | |
| import kotlinx.coroutines.flow.StateFlow | |
| import kotlinx.coroutines.flow.asStateFlow | |
| import kotlinx.coroutines.flow.update |
| object TracerState { | ||
| private val _status = MutableStateFlow(TraceStatus()) | ||
| val status: StateFlow<TraceStatus> = _status.asStateFlow() | ||
|
|
||
| fun update(transform: (TraceStatus) -> TraceStatus) { | ||
| _status.value = transform(_status.value) | ||
| } | ||
|
|
||
| fun reset() { | ||
| _status.value = TraceStatus(rootAvailable = _status.value.rootAvailable) | ||
| } | ||
| } |
There was a problem hiding this comment.
The custom update function is not thread-safe or atomic. Since TracerState.update is called concurrently from multiple background threads (e.g., readerThread, snapshotThread, and the main thread), concurrent updates can overwrite each other, leading to lost updates in UI counters. Use the built-in, atomic MutableStateFlow.update extension function instead.
| object TracerState { | |
| private val _status = MutableStateFlow(TraceStatus()) | |
| val status: StateFlow<TraceStatus> = _status.asStateFlow() | |
| fun update(transform: (TraceStatus) -> TraceStatus) { | |
| _status.value = transform(_status.value) | |
| } | |
| fun reset() { | |
| _status.value = TraceStatus(rootAvailable = _status.value.rootAvailable) | |
| } | |
| } | |
| object TracerState { | |
| private val _status = MutableStateFlow(TraceStatus()) | |
| val status: StateFlow<TraceStatus> = _status.asStateFlow() | |
| fun update(transform: (TraceStatus) -> TraceStatus) { | |
| _status.update(transform) | |
| } | |
| fun reset() { | |
| _status.update { TraceStatus(rootAvailable = it.rootAvailable) } | |
| } | |
| } |
| private fun monoToWall(monoNs: Long): String { | ||
| // SimpleDateFormat only resolves milliseconds; pad to the schema's | ||
| // microsecond shape (YYYY-MM-DD HH:MM:SS.ffffff) with a trailing 000. | ||
| val realNs = monoNs + realOffset | ||
| val fmt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US) | ||
| return fmt.format(java.util.Date(realNs / 1_000_000L)) + "000" | ||
| } |
There was a problem hiding this comment.
Creating a new SimpleDateFormat and Date instance on every single block I/O completion event (which can happen thousands of times per second) introduces significant garbage collection pressure and CPU overhead on Android. Reuse a single SimpleDateFormat instance wrapped in a ThreadLocal to ensure thread safety while avoiding repeated allocations.
private fun monoToWall(monoNs: Long): String {
val realNs = monoNs + realOffset
return wallFormat.get().format(java.util.Date(realNs / 1_000_000L)) + "000"
}| companion object { | ||
| fun checkRoot(): Boolean = RootShell().available() | ||
| } |
There was a problem hiding this comment.
| fun parseIssue(body: String): Issue? { | ||
| val m = ISSUE.find(body.trim()) ?: return null | ||
| val (dev, rwbs, bytes, sector, nsect) = m.destructured | ||
| return Issue(devToMajMin(dev), rwbs, bytes.toLong(), sector.toLong(), nsect.toLong()) | ||
| } | ||
|
|
||
| fun parseComplete(body: String): Complete? { | ||
| val m = COMPLETE.find(body.trim()) ?: return null | ||
| val (dev, rwbs, sector, nsect, err) = m.destructured | ||
| return Complete(devToMajMin(dev), rwbs, sector.toLong(), nsect.toLong(), err.toInt()) | ||
| } |
There was a problem hiding this comment.
Calling body.trim() allocates a new string on every single ftrace line parsed. Since the LINE regex in parseCommon already strips leading spaces before the body group, and trailing spaces do not affect the matching behavior of find(), body.trim() is redundant and can be safely removed to avoid unnecessary allocations.
| fun parseIssue(body: String): Issue? { | |
| val m = ISSUE.find(body.trim()) ?: return null | |
| val (dev, rwbs, bytes, sector, nsect) = m.destructured | |
| return Issue(devToMajMin(dev), rwbs, bytes.toLong(), sector.toLong(), nsect.toLong()) | |
| } | |
| fun parseComplete(body: String): Complete? { | |
| val m = COMPLETE.find(body.trim()) ?: return null | |
| val (dev, rwbs, sector, nsect, err) = m.destructured | |
| return Complete(devToMajMin(dev), rwbs, sector.toLong(), nsect.toLong(), err.toInt()) | |
| } | |
| fun parseIssue(body: String): Issue? { | |
| val m = ISSUE.find(body) ?: return null | |
| val (dev, rwbs, bytes, sector, nsect) = m.destructured | |
| return Issue(devToMajMin(dev), rwbs, bytes.toLong(), sector.toLong(), nsect.toLong()) | |
| } | |
| fun parseComplete(body: String): Complete? { | |
| val m = COMPLETE.find(body) ?: return null | |
| val (dev, rwbs, sector, nsect, err) = m.destructured | |
| return Complete(devToMajMin(dev), rwbs, sector.toLong(), nsect.toLong(), err.toInt()) | |
| } |
| data class DsRow( | ||
| val operation: String, | ||
| val pid: Any, // Int, or "" when the issue was not seen | ||
| val tid: String, | ||
| val command: String, | ||
| val sector: Long, | ||
| val size: Long, | ||
| val latencyMs: Any, // Double, or "" when unknown | ||
| val device: String, | ||
| val flags: String, | ||
| val cpuId: Int, | ||
| val ppid: String, | ||
| val queueLatencyMs: String, | ||
| val commandFlags: String, | ||
| val operationCode: String, | ||
| val requestId: Any, // Long, or "" when the issue was not seen | ||
| val monoNs: Long, | ||
| ) |
There was a problem hiding this comment.
Using Any to represent union types like Int | String or Double | String is highly unidiomatic in Kotlin and bypasses compile-time type safety. Use standard nullable types (Int?, Double?, Long?) instead. Since Csv.field already maps null to "", this change is fully compatible and much cleaner.
| data class DsRow( | |
| val operation: String, | |
| val pid: Any, // Int, or "" when the issue was not seen | |
| val tid: String, | |
| val command: String, | |
| val sector: Long, | |
| val size: Long, | |
| val latencyMs: Any, // Double, or "" when unknown | |
| val device: String, | |
| val flags: String, | |
| val cpuId: Int, | |
| val ppid: String, | |
| val queueLatencyMs: String, | |
| val commandFlags: String, | |
| val operationCode: String, | |
| val requestId: Any, // Long, or "" when the issue was not seen | |
| val monoNs: Long, | |
| ) | |
| data class DsRow( | |
| val operation: String, | |
| val pid: Int?, // null when the issue was not seen | |
| val tid: String, | |
| val command: String, | |
| val sector: Long, | |
| val size: Long, | |
| val latencyMs: Double?, // null when unknown | |
| val device: String, | |
| val flags: String, | |
| val cpuId: Int, | |
| val ppid: String, | |
| val queueLatencyMs: String, | |
| val commandFlags: String, | |
| val operationCode: String, | |
| val requestId: Long?, // null when the issue was not seen | |
| val monoNs: Long, | |
| ) |
| } else { | ||
| // Completion with no recorded issue (started before tracing began). | ||
| DsRow( | ||
| operation = operation, | ||
| pid = "", | ||
| tid = "", | ||
| command = c.comm.take(16), | ||
| sector = info.sector, | ||
| size = info.nsect * 512, | ||
| latencyMs = "", | ||
| device = info.device, | ||
| flags = flags, | ||
| cpuId = c.cpu, | ||
| ppid = "", | ||
| queueLatencyMs = "", | ||
| commandFlags = "", | ||
| operationCode = "", | ||
| requestId = "", | ||
| monoNs = c.monoNs, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Update the fallback completion row instantiation to use idiomatic null values instead of empty strings.
} else {
// Completion with no recorded issue (started before tracing began).
DsRow(
operation = operation,
pid = null,
tid = "",
command = c.comm.take(16),
sector = info.sector,
size = info.nsect * 512,
latencyMs = null,
device = info.device,
flags = flags,
cpuId = c.cpu,
ppid = "",
queueLatencyMs = "",
commandFlags = "",
operationCode = "",
requestId = null,
monoNs = c.monoNs,
)
}| val ts = nowFileStamp() | ||
| val name = "${stream}_${ts}_${"%04d".format(seq.getValue(stream))}.csv.gz" | ||
| val file = File(File(sessionDir, stream), name) | ||
| GZIPOutputStream(FileOutputStream(file)).bufferedWriter(Charsets.UTF_8).use { w -> |
There was a problem hiding this comment.
GZIPOutputStream is not internally buffered when writing to the underlying FileOutputStream. Wrapping the FileOutputStream in a BufferedOutputStream (via .buffered()) before passing it to GZIPOutputStream significantly reduces the number of disk write system calls and improves write performance.
| GZIPOutputStream(FileOutputStream(file)).bufferedWriter(Charsets.UTF_8).use { w -> | |
| GZIPOutputStream(FileOutputStream(file).buffered()).bufferedWriter(Charsets.UTF_8).use { w -> |
Fix the CI compile failure: Kotlin block comments nest, so the literal "system_spec/*.json" inside KDoc opened an unbalanced /* that swallowed the rest of TraceWriter.kt and Snappers.kt, cascading into many "unresolved reference" errors. Reworded those comments. Verified the full engine compiles on the JVM (Kotlin 1.9.24) and the parser/pairer unit tests still pass (8/8). Also address the PR review: - TracerState.update now uses the atomic MutableStateFlow.update (no lost updates from concurrent reader/snapshot/main threads). - Add TimeFmt: thread-local SimpleDateFormat reused on the hot per-ds-row path instead of allocating one per event (TraceEngine, Snappers, TraceWriter). - Drop redundant body.trim() allocations in FtraceParser. - Buffer GZIPOutputStream's underlying FileOutputStream. - BlockPairer.DsRow uses nullable types (Int?/Double?/Long?) instead of Any; Csv.field already renders null as "". Tests updated to assert null. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbHpkJ1MdVfGz9bi6Ekb1i
…proc tests Address review findings on PR #1: - BlockPairer: cap the outstanding-issue map (LinkedHashMap, maxInflight, evict oldest) so issues whose completion is never seen can't grow memory unbounded on a long trace. - FtraceControl.teardownScript: pkill the `cat trace_pipe` reader, since some su implementations don't forward SIGTERM to the child and it would keep holding the ring buffer after stop. - Extract a pure ProcSnapper.parsePsLine and add unit tests for it. - Add TraceWriter gzip-output tests (rotation, header, snapshot flush-on-demand). - Add a BlockPairer eviction test. - Remove the unused material-icons-extended dependency and the dead TracerState.reset(); tidy fully-qualified refs in TraceEngine. All 14 JVM unit tests pass; full engine compiles (Kotlin 1.9.24). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbHpkJ1MdVfGz9bi6Ekb1i
Summary
Adds a native Android app (
app/module) that runs the block-I/O collector on-device with a Jetpack Compose Start/Stop UI and a foreground service, instead of driving the Python CLI overadb. It emits the same CSV schema as the Python tracer and the Linux tracer — only the compression container differs (.csv.gzvs.csv.zst).This builds on the Python CLI already in the repo (which remains the reference collector and host driver).
How it works
su) shell that setstrace_clock=mono, enablesblock/block_rq_issue+block/block_rq_complete, and streamstrace_pipe. Kotlin pairs issue→complete by(device, sector)to recover device latency + a monotonicrequest_id, and restores ftrace state on stop.ps -A(process) +getprop//proc(system spec).manifest.json(anonymized machine id,CLOCK_MONOTONIC→REALTIMEoffset, row counts) underAndroid/data/com.cachemon.iotracer/files/traces/<session>/.Schema parity
Schema.kt,FtraceParser.kt, andBlockPairer.ktare 1:1 ports ofschema.py/parsers.py(sameSCHEMA_VERSION = 3, same column order) and must be kept in sync — noted indocs/ANDROID_APP.md.Testing
app/src/test) mirrortests/test_parsers.py: common-header parsing, rwbs decoding, issue/complete pairing + latency, reused-sector disambiguation, unmatched completions. Verified locally — compiled the pure-Kotlin engine with Kotlin 1.9.24 and ran the tests: 8 passed..github/workflows/android.yml) runs:app:testDebugUnitTestand assembles the debug APK on every push/PR, uploading the APK as an artifact. (The full Android build needs the Android SDK, which isn't in the dev sandbox — CI is the build gate.)Build & run
Notes
/sys/kernel/tracingviasu); minSdk 26.Files
app/— Gradle module, manifest, Kotlin engine + service + Compose UI, unit testsbuild.gradle.kts,settings.gradle.kts,gradle.properties.github/workflows/android.ymldocs/ANDROID_APP.md; README pointer🤖 Generated with Claude Code
https://claude.ai/code/session_01VbHpkJ1MdVfGz9bi6Ekb1i
Generated by Claude Code