android_adbShell is transport-aware: raw argv on-device, shell string on host¶
android_adbShell now picks its dispatch form by transport. On the host (dadb →
adbd) it keeps the shell-escaped string plus the trailing exit-code sentinel,
because adbd evaluates the command through sh -c. On-device it sends the
raw argv tokens (no escaping, no sentinel) through
AndroidDeviceCommandExecutor.executeShellCommandArgs, because the on-device
transport (UiAutomationConnection.executeShellCommand → Runtime.exec) has no
shell — it whitespace-splits and execs the tokens directly. The two paths are
selected by a new AndroidDeviceCommandExecutor.usesShellInterpreter flag
(true on the JVM/host actual, false on the Android/on-device actual). The
on-device path is additionally bounded by a 60s interruptible timeout.
Background¶
A tool authored as a .ts scripted tool can compose android_adbShell, and the
same composition is expected to work whether the call is dispatched on the daemon
JVM or on the device’s QuickJS bundle path. Those two ends are not
shell-equivalent, and that mismatch was previously invisible:
- Host: the command travels over the dadb wire to
adbd, which runs it viash -c. Shell quoting and a; <sentinel> $?tail are honored, so the tool shell-escapes every argv element (making metacharacters literal — the load-bearing injection-safety property) and recovers the program’s exit code from the sentinel line. - On-device:
UiAutomationConnection.executeShellCommandhands the string toRuntime.exec, which splits on whitespace and execs the tokens with no shell interpreter. The exact same shell-escaped string is then fatal: a token likesubecomes the literal program name'su'(quotes included) →Cannot run program "'su'", and the sentinel tail is exec’d as literal arguments rather than evaluated.
This surfaced when a device-target launch flow was moved from a Kotlin
orchestrator to TypeScript. The Kotlin path dispatched a privileged
package-disable (su root pm disable <authenticator-pkg>, needed only on certain
hardware emulators) through executeShellCommandArgs — the join-with-spaces,
no-escaping path — so su resolved correctly. The TypeScript port routed the
same step through android_adbShell on the on-device transport, which
shell-escaped it. The launch failed on the first device call.
Worse, it didn’t fail fast: Runtime.exec’s IOException is raised in the
separate UiAutomation process and “cannot cross the Binder,” so the caller was
left blocked on the result pipe. With no per-call bound, the run hung until the
session inactivity watchdog abandoned it (~13 min) — every affected test
reported as a TIMEOUT with no driver log, screenshot, or tool log.
Decision¶
- Make the transport explicit. Add
usesShellInterpretertoAndroidDeviceCommandExecutorso callers can ask whether the device side runs a shell, rather than each caller hard-coding thesh -cassumption. - Branch
android_adbShellon it. Host → unchanged (escape + sentinel + exit-code detection). On-device → raw argv viaexecuteShellCommandArgs, no escaping and no sentinel (the no-shell transport offers no exit-code channel; only a failed launch throws, and that is surfaced as an error). - Bound the on-device dispatch. Run it on an interruptible IO dispatcher under a 60s cap so a wedged or missing-program exec fails fast with a clear error instead of hanging until the watchdog. Generous for a real shell-out on a slow CI emulator; far under the ~13-min watchdog.
The pure render decision is exercised by unit tests (joinCommandRawArgv must
not quote su, in contrast to joinCommandAsShellString which must) so a
regression that re-introduces escaping on the no-shell path fails a fast unit
test rather than a multi-minute device hang.
Consequences¶
- On-device
android_adbShellcannot observe a non-zero program exit (there is no shell to capture$?). A non-zero exit returns the program’s stdout as success; only a launch failure (missing binary, exec error) becomes an error. This matches the pre-existingexecuteShellCommandArgscontract. - On-device argv tokens must be whitespace-free (already enforced by
executeShellCommandArgs) — a single token with an embedded space cannot surviveRuntime.execre-splitting and is rejected up front. - The host path is unchanged, including its exit-code sentinel and
TRAILBLAZE_ADB_TIMEOUT_MSbound.