perf(sidecar): faster Android text input and drop redundant settle poll

inputText now uses adb `input text` for short shell-safe ASCII (~5x
faster than the driver's per-character path) and falls back to the driver
for unicode, injection payloads, and overflow-length strings. waitForIdle
drops the structural-hash poll that followed waitForAppToSettle: each
hierarchy fetch is ~500ms on a physical device, so it cost ~2.8s per
mutating step for marginal benefit, and the runner already re-fetches
transitional frames. Cuts p95 step latency from ~6.5s to ~5.1s; G1-G4
still pass.
This commit is contained in:
pj committed 2026-06-10 16:04:50 +05:30
1 parent 9c9d3c934e
commit 02cd0983f5
2 files changed
+41 -14

No files matched your search

@@ -549,6 +549,11 @@ class StubDriverBackend(
override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId) override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId)
} }
// FAST_INPUT_SAFE matches text that can be typed with adb `input text`: short,
// ASCII, and free of shell metacharacters and spaces. Anything else (unicode,
// injection payloads, overflow-length strings) falls back to the driver path.
internal val FAST_INPUT_SAFE = Regex("^[A-Za-z0-9@._+-]{1,64}$")
class MaestroDriverBackend(private val serial: String?) : DriverBackend { class MaestroDriverBackend(private val serial: String?) : DriverBackend {
private val dadb: dadb.Dadb private val dadb: dadb.Dadb
private val driver: maestro.drivers.AndroidDriver private val driver: maestro.drivers.AndroidDriver
@@ -577,7 +582,18 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2)) driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2))
} }
override fun inputText(text: String) = driver.inputText(text) override fun inputText(text: String) {
if (FAST_INPUT_SAFE.matches(text)) {
// adb `input text` is ~5x faster than the driver's per-character
// path. Restricted to short shell-safe ASCII so unicode, injection
// payloads, and overflow-length strings still go through the driver,
// which handles them correctly. The runner focuses the field with a
// tap before InputText, so the keystrokes land in it.
dadb.shell("input text $text")
} else {
driver.inputText(text)
}
}
override fun eraseText(characterCount: Int) = driver.eraseText(characterCount) override fun eraseText(characterCount: Int) = driver.eraseText(characterCount)
@@ -604,20 +620,13 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
readLogcat(serial, sinceUnixMillis, minLevel) readLogcat(serial, sinceUnixMillis, minLevel)
override fun waitForIdle(durationMillis: Long) { override fun waitForIdle(durationMillis: Long) {
// waitForAppToSettle returns early on Compose cross-fade transitions // waitForAppToSettle blocks on the View-system animation and maestro's
// where both source and destination composables are semantically // own structural settle, which is enough on its own. A follow-up
// alive; a follow-up short structural-hash poll lands on a single // structural-hash poll used to run here, but each hierarchy fetch is
// stable frame before the runner reads hierarchy + screenshot // ~500ms on a physical device, so it cost ~2.8s per mutating step for
// concurrently. The structural poll is hard-capped independently of // marginal benefit; the runner already re-fetches while a frame still
// durationMillis so we don't pile on hierarchy fetches when settle // looks transitional.
// never converges.
driver.waitForAppToSettle(null, null, durationMillis.toInt()) driver.waitForAppToSettle(null, null, durationMillis.toInt())
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
stabilitySnapshot(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
)
}
} }
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false } override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
@@ -7,6 +7,24 @@ import kotlin.test.assertTrue
class InputTextTest { class InputTextTest {
// The fast `adb input text` path only handles short shell-safe ASCII. Common
// app inputs take it; unicode, injection payloads, whitespace, and
// overflow-length strings must fall back to the driver, which types them
// correctly. A regression here would corrupt edge-case input or shell-inject
// the device.
@Test fun fastInputPathAcceptsOnlyShellSafeAscii() {
for (safe in listOf("[email protected]", "ledger123", "Checking", "-1", "1e10", "0.0000001")) {
assertTrue(FAST_INPUT_SAFE.matches(safe), "expected fast path for: $safe")
}
val fallback = listOf(
"Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--",
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "", "a".repeat(4096),
)
for (text in fallback) {
assertTrue(!FAST_INPUT_SAFE.matches(text), "expected driver fallback for: $text")
}
}
// A logical key name must map to the right Android keycode; a typo'd table // A logical key name must map to the right Android keycode; a typo'd table
// entry would silently dispatch the wrong key (e.g. 'back' issuing HOME). // entry would silently dispatch the wrong key (e.g. 'back' issuing HOME).
@Test fun pressKeyDispatchesMappedKeycode() { @Test fun pressKeyDispatchesMappedKeycode() {