prober build 4: newProcess fallback when the Shizuku UserService won't bind

Build 3 settled it: the Lenovo TB330FU/A15 never spawns the UserService
(two 25s bind attempts, binder alive, permission granted). Build 4 falls
back to the legacy Shizuku.newProcess remote-process API via reflection
and records exec_path in the evidence — whether that path works per
device is itself the capability question core-shizuku needs answered.

Also archives both build-3 reports: phone 7/7 incl. provoked NEIGH
transitions in ip_monitor; traceroute.udp4 handled a silent hop ("*")
correctly on both devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-30 15:10:58 +02:00
co-authored by Claude Opus 5
parent 0ed36d9103
commit 60989b67ef
6 changed files with 433 additions and 7 deletions
@@ -60,10 +60,14 @@ class ShizukuProbe : Probe {
var ok = 0
// 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)) {
val batch = runner.execBatch(battery, timeoutMs = 6000)
ev["exec_path"] = batch.execPath
for ((key, out) in batch.results) {
// 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++
if (!out.startsWith("SHIZUKU_") && !out.startsWith("EXEC_") &&
!out.startsWith("NEWPROCESS_") && out.isNotBlank()
) ok++
}
val verdict = if (ok >= 4) Verdict.SUPPORTED else if (ok >= 1) Verdict.PARTIAL else Verdict.UNSUPPORTED
ProbeResult.of(this@ShizukuProbe, verdict,
@@ -67,17 +67,27 @@ class ShizukuRunner(private val context: Context) {
.processNameSuffix("prober")
.version(1)
/** How the batch actually executed — recorded as evidence by the probe. */
data class BatchResult(
val execPath: String,
val results: LinkedHashMap<String, 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.
*
* If the UserService never binds (Lenovo TB330FU/A15: two 25 s attempts, spawn never
* happens), falls back to the legacy Shizuku.newProcess remote-process API via reflection —
* itself a capability question worth answering per device.
*
* 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> {
): BatchResult {
val out = LinkedHashMap<String, String>()
val bound = CompletableDeferred<IUserService?>()
val conn = object : ServiceConnection {
@@ -100,8 +110,7 @@ class ShizukuRunner(private val context: Context) {
svc = withTimeoutOrNull(25_000) { bound.await() }
}
if (svc == null) {
commands.forEach { (key, _) -> out[key] = "SHIZUKU_BIND_TIMEOUT" }
return out
return execBatchViaNewProcess(commands, timeoutMs)
}
for ((key, cmd) in commands) {
out[key] = try {
@@ -118,6 +127,50 @@ class ShizukuRunner(private val context: Context) {
} finally {
runCatching { Shizuku.unbindUserService(serviceArgs, conn, true) }
}
return out
return BatchResult("UserService", out)
}
/**
* Legacy path: Shizuku.newProcess (private in API 13, reached via reflection — repo
* convention for uncertain APIs). Returns a java.lang.Process whose stdout is read with a
* deadline. Whether this path works per device/Shizuku build is itself evidence the
* production core-shizuku module needs.
*/
private fun execBatchViaNewProcess(
commands: List<Pair<String, String>>,
timeoutMs: Int,
): BatchResult {
val out = LinkedHashMap<String, String>()
val method = runCatching {
Shizuku::class.java.getDeclaredMethod(
"newProcess",
Array<String>::class.java, Array<String>::class.java, String::class.java,
).apply { isAccessible = true }
}.getOrNull()
if (method == null) {
commands.forEach { (key, _) -> out[key] = "SHIZUKU_BIND_TIMEOUT; newProcess API absent" }
return BatchResult("UserService bind failed; newProcess absent", out)
}
for ((key, cmd) in commands) {
out[key] = runCatching {
val proc = method.invoke(null, arrayOf("sh", "-c", cmd), null, null) as Process
val output = StringBuilder()
val reader = Thread {
runCatching {
proc.inputStream.bufferedReader().forEachLine { line ->
if (output.length < 64 * 1024) output.append(line).append('\n')
}
}
}
reader.start()
reader.join(timeoutMs.toLong())
if (reader.isAlive) proc.destroy()
output.toString().ifBlank { "EXEC_TIMEOUT(newProcess)" }
}.getOrElse { e ->
val cause = (e as? java.lang.reflect.InvocationTargetException)?.cause ?: e
"NEWPROCESS_ERROR: ${cause.message ?: cause.javaClass.simpleName}"
}
}
return BatchResult("newProcess fallback (UserService bind failed)", out)
}
}