Skip to content

Native rooted Android app for block-I/O tracing - #1

Merged
1a1a11a merged 3 commits into
mainfrom
claude/gallant-fermat-xau2al
Jun 17, 2026
Merged

Native rooted Android app for block-I/O tracing#1
1a1a11a merged 3 commits into
mainfrom
claude/gallant-fermat-xau2al

Conversation

@1a1a11a

@1a1a11a 1a1a11a commented Jun 17, 2026

Copy link
Copy Markdown
Member

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 over adb. It emits the same CSV schema as the Python tracer and the Linux tracer — only the compression container differs (.csv.gz vs .csv.zst).

This builds on the Python CLI already in the repo (which remains the reference collector and host driver).

How it works

MainActivity (Compose UI)  ──►  TracerService (foreground)  ──►  TraceEngine
                                                                   ├─ RootShell ......... su exec + streaming
                                                                   ├─ FtraceControl ..... enable/stream/teardown tracefs
                                                                   ├─ FtraceParser ...... port of parsers.py
                                                                   ├─ BlockPairer ....... issue→complete latency
                                                                   ├─ Proc/SystemSnapper
                                                                   └─ TraceWriter ....... gzip CSV + manifest.json
  • Block I/O: the service opens a root (su) shell that sets trace_clock=mono, enables block/block_rq_issue + block/block_rq_complete, and streams trace_pipe. Kotlin pairs issue→complete by (device, sector) to recover device latency + a monotonic request_id, and restores ftrace state on stop.
  • Snapshots: periodic ps -A (process) + getprop//proc (system spec).
  • Output: gzip CSV streams + manifest.json (anonymized machine id, CLOCK_MONOTONIC→REALTIME offset, row counts) under Android/data/com.cachemon.iotracer/files/traces/<session>/.
  • Falls back to snapshot-only when root/block tracepoints aren't available.

Schema parity

Schema.kt, FtraceParser.kt, and BlockPairer.kt are 1:1 ports of schema.py / parsers.py (same SCHEMA_VERSION = 3, same column order) and must be kept in sync — noted in docs/ANDROID_APP.md.

Testing

  • JVM unit tests (app/src/test) mirror tests/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.
  • CI (.github/workflows/android.yml) runs :app:testDebugUnitTest and 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

gradle :app:assembleDebug          # or open in Android Studio
adb install app-debug.apk          # rooted/userdebug device
# Launch "IO Tracer", grant su, Start → reproduce workload → Stop
adb pull /sdcard/Android/data/com.cachemon.iotracer/files/traces ./traces

Notes

  • Requires root for block tracing (reads /sys/kernel/tracing via su); minSdk 26.
  • No Gradle wrapper jar is committed — Android Studio generates it, and CI uses a pinned system Gradle 8.7. (Happy to commit the wrapper if you'd prefer.)

Files

  • app/ — Gradle module, manifest, Kotlin engine + service + Compose UI, unit tests
  • build.gradle.kts, settings.gradle.kts, gradle.properties
  • .github/workflows/android.yml
  • docs/ANDROID_APP.md; README pointer

🤖 Generated with Claude Code

https://claude.ai/code/session_01VbHpkJ1MdVfGz9bi6Ekb1i


Generated by Claude Code

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
Copilot AI review requested due to automatic review settings June 17, 2026 00:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +3 to +5
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add the kotlinx.coroutines.flow.update import to support atomic updates on MutableStateFlow.

Suggested change
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

Comment on lines +19 to +30
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)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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) }
}
}

Comment on lines +147 to +153
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"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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"
    }

Comment on lines +158 to +160
companion object {
fun checkRoot(): Boolean = RootShell().available()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a thread-local SimpleDateFormat instance to the companion object to be reused by monoToWall.

    companion object {
        fun checkRoot(): Boolean = RootShell().available()

        private val wallFormat = ThreadLocal.withInitial {
            java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US)
        }
    }

Comment on lines +81 to +91
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())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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())
}

Comment on lines +15 to +32
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,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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,
)

Comment on lines +91 to +111
} 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,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 ->

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
GZIPOutputStream(FileOutputStream(file)).bufferedWriter(Charsets.UTF_8).use { w ->
GZIPOutputStream(FileOutputStream(file).buffered()).bufferedWriter(Charsets.UTF_8).use { w ->

claude added 2 commits June 17, 2026 01:02
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
@1a1a11a
1a1a11a merged commit f3ec915 into main Jun 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants