fix(android): run iperf3 tests off the Flutter platform thread

runIperfLive() blocks for the full duration of a test, but the MethodChannel
handler called it directly on the platform thread. That froze the UI for the
whole run, and because output reaches Dart by posting to the main looper, it
also meant no line could be delivered until the test was already over — the
live output the bridge streams had nowhere to go. Tests now run on a
dedicated single-thread executor and the channel call returns immediately.

MainThreadCallback's constructor properties were named onOutput/onError/
onComplete, the same names as the interface methods overriding them, so
`mainHandler.post { onOutput(line) }` resolved to the member function rather
than the lambda and re-posted itself instead of reaching the channel. Renamed
to emit* so the call cannot bind to the wrong declaration.

A failed System.loadLibrary now surfaces as a NATIVE_LIB_MISSING channel
error rather than leaving every later call to hang, and detaching from the
engine stops an in-flight test whose callbacks would fire into a dead
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:03:22 +09:00
parent 2bba647a7b
commit f410a7e0d3
@@ -4,24 +4,38 @@ import android.os.Handler
import android.os.Looper
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
/**
* Kotlin binding layer for native iperf3 via JNI.
*
* iperf3 runs natively inside the app process — no Process.start() needed,
* no SELinux restrictions. Output is streamed live to the Flutter UI via
* the IperfCallback interface, with all callbacks posted to the main thread.
* no SELinux restrictions.
*
* Threading: runIperfLive() blocks for the entire duration of the test, so it
* runs on a dedicated worker thread. Running it on the platform thread would
* freeze the UI and — because output is delivered by posting to the main
* looper — stall every callback until the test was already over.
*/
object IperfRunner : FlutterPlugin {
private const val CHANNEL = "com.novoyuuparosk.iperf3gui/iperf"
private var channel: MethodChannel? = null
private var appContext: android.content.Context? = null
private val mainHandler = Handler(Looper.getMainLooper())
/** Serialises test runs: one test at a time, off the platform thread. */
private val runExecutor =
Executors.newSingleThreadExecutor { r -> Thread(r, "iperf3-run") }
private var nativeLibraryLoaded = false
init {
try {
android.util.Log.i("IperfRunner", "Loading native library...")
System.loadLibrary("iperf3jni")
nativeLibraryLoaded = true
android.util.Log.i("IperfRunner", "Native library loaded successfully")
} catch (e: UnsatisfiedLinkError) {
android.util.Log.e("IperfRunner", "Failed to load native library: ${e.message}")
@@ -31,62 +45,87 @@ object IperfRunner : FlutterPlugin {
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
appContext = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "com.novoyuuparosk.iperf3gui/iperf")
channel = MethodChannel(binding.binaryMessenger, CHANNEL)
channel?.setMethodCallHandler { call, result ->
when (call.method) {
"runIperf" -> {
val args = call.argument<List<String>>("args")?.toTypedArray()
if (args != null) {
// Set temp dir to app's cache dir — /data/local/tmp is NOT writable
appContext?.cacheDir?.absolutePath?.let { cacheDir ->
setTempDir(cacheDir)
}
// Wrap callbacks so they always run on the main thread.
// The native reader thread cannot call invokeMethod directly.
val safeCallback = MainThreadCallback(
onOutput = { line ->
channel?.invokeMethod("onOutput", mapOf("output" to line))
},
onError = { error ->
channel?.invokeMethod("onError", mapOf("error" to error))
},
onComplete = {
channel?.invokeMethod("onComplete", emptyMap<String, Any>())
}
)
runIperfLive(args, safeCallback)
result.success(null)
} else {
result.error("INVALID_ARGS", "args must be a String array", null)
}
}
"stopIperf" -> {
val safeCallback = MainThreadCallback(
onOutput = { line ->
channel?.invokeMethod("onOutput", mapOf("output" to line))
},
onError = { error ->
channel?.invokeMethod("onError", mapOf("error" to error))
},
onComplete = {
channel?.invokeMethod("onComplete", emptyMap<String, Any>())
}
)
forceStopIperfTest(safeCallback)
result.success(null)
}
"isTestRunning" -> {
result.success(isTestRunning())
}
"runIperf" -> handleRunIperf(call.argument<List<String>>("args"), result)
"stopIperf" -> handleStopIperf(result)
"isTestRunning" -> result.success(nativeLibraryLoaded && isTestRunning())
else -> result.notImplemented()
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// Stop any in-flight test: its callbacks would otherwise fire into a
// dead channel, and the worker thread would keep the socket open.
if (nativeLibraryLoaded && isTestRunning()) {
forceStopIperfTest(NoopCallback)
}
channel?.setMethodCallHandler(null)
channel = null
appContext = null
}
private fun handleRunIperf(args: List<String>?, result: MethodChannel.Result) {
if (!nativeLibraryLoaded) {
result.error(
"NATIVE_LIB_MISSING",
"libiperf3jni.so failed to load — cannot run iperf3",
null,
)
return
}
if (args == null) {
result.error("INVALID_ARGS", "args must be a String array", null)
return
}
if (isTestRunning()) {
result.error("ALREADY_RUNNING", "A test is already running", null)
return
}
// Set temp dir to the app's cache dir — mkstemp() in iperf_new_stream()
// cannot write to /data/local/tmp from inside the app sandbox.
appContext?.cacheDir?.absolutePath?.let { setTempDir(it) }
val argv = args.toTypedArray()
val callback = mainThreadCallback()
// Hand back control immediately; progress arrives via the callback.
result.success(null)
runExecutor.execute {
try {
runIperfLive(argv, callback)
} catch (t: Throwable) {
android.util.Log.e("IperfRunner", "runIperfLive threw", t)
callback.onError("Native error: ${t.message}")
callback.onComplete()
}
}
}
private fun handleStopIperf(result: MethodChannel.Result) {
if (!nativeLibraryLoaded) {
result.success(null)
return
}
forceStopIperfTest(mainThreadCallback())
result.success(null)
}
/**
* Build a callback that forwards native output to Dart, hopping to the
* main thread first: MethodChannel.invokeMethod must not be called from
* the native reader thread.
*/
private fun mainThreadCallback(): IperfCallback = MainThreadCallback(
emitOutput = { line -> channel?.invokeMethod("onOutput", mapOf("output" to line)) },
emitError = { error -> channel?.invokeMethod("onError", mapOf("error" to error)) },
emitComplete = { channel?.invokeMethod("onComplete", emptyMap<String, Any>()) },
)
// ─────────────────────────────────────────────────────────────────
// Native method bindings
// ─────────────────────────────────────────────────────────────────
@@ -107,23 +146,35 @@ object IperfRunner : FlutterPlugin {
}
/**
* Thread-safe wrapper: posts all callbacks to the main thread.
* This is required because the native reader thread cannot safely
* call MethodChannel.invokeMethod from a background thread.
* Posts every callback to the main thread.
*
* The lambdas are named `emit*` rather than `on*` on purpose: a constructor
* property with the same name as the overriding method would make
* `onOutput(line)` resolve back to this class's own method — an endless
* post-and-re-post loop that never reaches the channel.
*/
private class MainThreadCallback(
private val onOutput: (String) -> Unit,
private val onError: (String) -> Unit,
private val onComplete: () -> Unit
private val emitOutput: (String) -> Unit,
private val emitError: (String) -> Unit,
private val emitComplete: () -> Unit,
) : IperfCallback {
override fun onOutput(line: String) {
mainHandler.post { onOutput(line) }
mainHandler.post { emitOutput(line) }
}
override fun onError(error: String) {
mainHandler.post { onError(error) }
mainHandler.post { emitError(error) }
}
override fun onComplete() {
mainHandler.post { onComplete() }
mainHandler.post { emitComplete() }
}
}
/** Used when a stop is requested with no channel left to report to. */
private object NoopCallback : IperfCallback {
override fun onOutput(line: String) = Unit
override fun onError(error: String) = Unit
override fun onComplete() = Unit
}
}