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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user