prober: fix three bugs exposed by the first device report (OnePlus 15/A16)

- AndroidManifest: add CHANGE_NETWORK_STATE — requestNetwork threw
  SecurityException, multinetwork.request_and_bind could never run.
- IcmpProbe: format rtt_ms with Locale.ROOT — Austrian locale produced
  "38,1" in the JSON report.
- ShizukuRunner/ShizukuProbe: bind the UserService once per battery
  (execBatch) instead of per command; the per-command bind/unbind raced
  Shizuku and 3/7 commands died on SHIZUKU_BIND_TIMEOUT.

Archive the report at echolot-prober/reports/, record findings in
build-status.md. Notable: errqueue path fully reachable on Android 16 —
the native shim may be unnecessary on modern devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-30 11:19:14 +02:00
co-authored by Claude Opus 5
parent 6c29d0c039
commit 4c61c9bba8
8 changed files with 255 additions and 13 deletions
@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Normal (install-time) permission; required by ConnectivityManager.requestNetwork.
Missing it made multinetwork.request_and_bind ERROR on the first device run. -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -13,6 +13,7 @@ import java.io.FileDescriptor
import java.net.Inet4Address
import java.net.InetAddress
import java.nio.ByteBuffer
import java.util.Locale
/**
* Probes the unprivileged ICMP echo path: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP).
@@ -55,7 +56,9 @@ class IcmpProbe(
val received = Os.recvfrom(fd, buf, 0, null)
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
ev["bytes_received"] = received.toString()
ev["rtt_ms"] = "%.1f".format(rttMs)
// Locale.ROOT: the device's locale must not leak into the report ("38,1" broke
// the schema's number format on the first Austrian-locale device).
ev["rtt_ms"] = "%.1f".format(Locale.ROOT, rttMs)
// ICMP datagram (ping) sockets deliver the ICMP message with NO IP header, so the
// type byte is at offset 0 for both families. v4 echo reply = 0, v6 echo reply = 129.
@@ -56,8 +56,9 @@ class ShizukuProbe : Probe {
}
var ok = 0
for ((key, cmd) in battery) {
val out = runner.exec(cmd, timeoutMs = 6000)
// One bind for the whole battery — per-command binding raced Shizuku's unbind and
// produced spurious SHIZUKU_BIND_TIMEOUTs (3/7 on the OnePlus 15).
for ((key, out) in runner.execBatch(battery, timeoutMs = 6000)) {
// Truncate each excerpt so evidence stays readable; full capture is future work.
ev[key] = out.trim().take(1200)
if (!out.startsWith("SHIZUKU_") && !out.startsWith("EXEC_") && out.isNotBlank()) ok++
@@ -67,8 +67,18 @@ class ShizukuRunner(private val context: Context) {
.processNameSuffix("prober")
.version(1)
/** Bind the UserService, run one command, and return combined output (or an error string). */
suspend fun exec(command: String, timeoutMs: Int = 8000): String {
/**
* Bind the UserService ONCE, run every command against it, then unbind. The first device run
* (OnePlus 15) showed why per-command binding is wrong: unbind of command N races the bind of
* command N+1 inside Shizuku, and 3/7 commands died on SHIZUKU_BIND_TIMEOUT.
*
* Returns one output (or error string) per command, keyed like the input.
*/
suspend fun execBatch(
commands: List<Pair<String, String>>,
timeoutMs: Int = 8000,
): LinkedHashMap<String, String> {
val out = LinkedHashMap<String, String>()
val bound = CompletableDeferred<IUserService?>()
val conn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
@@ -78,16 +88,28 @@ class ShizukuRunner(private val context: Context) {
}
override fun onServiceDisconnected(name: ComponentName?) {}
}
return try {
try {
Shizuku.bindUserService(serviceArgs, conn)
val svc = withTimeoutOrNull(10000) { bound.await() }
?: return "SHIZUKU_BIND_TIMEOUT"
withTimeoutOrNull(timeoutMs.toLong() + 4000) { svc.exec(command, timeoutMs) }
?: "EXEC_TIMEOUT"
if (svc == null) {
commands.forEach { (key, _) -> out[key] = "SHIZUKU_BIND_TIMEOUT" }
return out
}
for ((key, cmd) in commands) {
out[key] = try {
withTimeoutOrNull(timeoutMs.toLong() + 4000) { svc.exec(cmd, timeoutMs) }
?: "EXEC_TIMEOUT"
} catch (e: Throwable) {
"SHIZUKU_ERROR: ${e.message ?: e.javaClass.simpleName}"
}
}
} catch (e: Throwable) {
"SHIZUKU_ERROR: ${e.message ?: e.javaClass.simpleName}"
commands.forEach { (key, _) ->
out.putIfAbsent(key, "SHIZUKU_ERROR: ${e.message ?: e.javaClass.simpleName}")
}
} finally {
runCatching { Shizuku.unbindUserService(serviceArgs, conn, true) }
}
return out
}
}