Compare commits

...

2 Commits

Author SHA1 Message Date
irene af26a1c385 fix(jni): set app cache dir as temp dir and wrap callbacks in Handler
CI / Analyze & Test (push) Failing after 43s
- Set app cache dir as temp directory for mkstemp() to avoid EACCES
  on Android sandbox (iperf_set_test_template).
- Wrap all MethodChannel callbacks in MainThreadCallback with
  Handler(Looper.getMainLooper()) to prevent SIGABRT from calling
  invokeMethod from native reader thread.
- Add setTempDir JNI function and logging for test return value.
2026-08-26 18:30:20 +09:00
irene a31cdef775 fix: resolve argv[0] misparse and SIGSEGV hang in JNI bridge
Two root causes prevented iperf3 tests from running:

1. argv[0] bug: iperf_parse_arguments() expects argv[0] to be the
   program name. We passed Dart args directly, so argv[0] was '-c',
   which iperf3 misinterpreted as the program name → 'Argument parse
   error: must either be a client (-c) or server (-s)'. Fixed by
   prepending "iperf3" as argv[0] and shifting user args to argv[1+].

2. Signal handling: iperf_catch_sigend() installs handlers that use
   longjmp() to a jmp_buf, but we never initialized the buffer with
   setjmp(). Any signal during iperf_run_client() caused SIGSEGV or
   hang. Added setjmp/longjmp wrapper matching main.c's pattern,
   SIGPIPE=SIG_IGN, and a 30s connect_timeout to avoid DNS hangs.

Also extracted iperf_cleanup_and_signal() to deduplicate the
normal-return and longjmp paths. Added diagnostic logging to
IperfRunner.kt's library loading for faster future debugging.
2026-08-26 01:52:37 +09:00
2 changed files with 245 additions and 106 deletions
+184 -61
View File
@@ -4,6 +4,13 @@
* This file exposes iperf3's API to Java/Kotlin via JNI, enabling
* the Flutter app to run iperf3 tests natively inside the app process
* (no Process.start needed, no SELinux restrictions).
*
* SIGNAL HANDLING NOTE:
* iperf3's run() function (called from iperf_run_client()) uses
* setjmp/longjmp for graceful signal handling. The JNI bridge must
* replicate this setup — otherwise iperf_catch_sigend() installs
* handlers that longjmp to an unset buffer, causing undefined
* behaviour (typically a SIGSEGV or hang).
*/
#include <stdio.h>
@@ -11,6 +18,8 @@
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <setjmp.h>
#include <signal.h>
#include <android/log.h>
#include <jni.h>
#include <pthread.h>
@@ -27,6 +36,7 @@
/* Global state for controlling the iperf test */
static struct iperf_test *g_test = NULL;
static volatile int g_stop = 0;
static char g_tmp_template[1024] = "/data/local/tmp/iperf3.XXXXXX";
/* Per-call state to pass between JNI and the reader thread.
* We store the callback object (global ref) and method IDs.
@@ -159,6 +169,62 @@ static void free_argv(char **argv, int argc) {
free(argv);
}
/* ------------------------------------------------------------------ */
/* Internal: clean up after iperf_run_client returns (normal path). */
/* Must be called only on the normal return path (NOT after longjmp). */
/* ------------------------------------------------------------------ */
static void iperf_cleanup_and_signal(JNIEnv *env,
struct iperf_run_state *state,
char **argv, int argc,
int ret, int stopped) {
/* Wait for reader thread to finish */
pthread_join(state->reader_thread, NULL);
/* Flush and close output */
fflush(state->outfile);
fclose(state->outfile);
/* Clean up iperf */
iperf_free_test(g_test);
g_test = NULL;
/* Free argv */
free_argv(argv, argc);
/* Signal result to Java */
if (stopped) {
signal_output(state, "[iPerf JNI] Test was stopped by user.\n");
} else if (ret < 0) {
char errbuf[256];
snprintf(errbuf, sizeof(errbuf),
"[iPerf JNI] Test failed: %s\n",
iperf_strerror(i_errno));
signal_error(state, errbuf);
} else {
signal_output(state, "[iPerf JNI] Test completed successfully.\n");
}
/* Signal completion to Java */
signal_complete(state);
/* Free global ref and state struct */
(*env)->DeleteGlobalRef(env, state->callback_ref);
free(state);
}
/* ------------------------------------------------------------------ */
/* Per-call signal handling state. */
/* Must be static so it survives across the longjmp back to setjmp. */
/* ------------------------------------------------------------------ */
static jmp_buf g_sigend_jmp_buf;
static volatile int g_signed_sig = 0;
/* Signal handler — mirrors main.c::sigend_handler */
static void sigend_handler(int sig) {
g_signed_sig = sig;
longjmp(g_sigend_jmp_buf, 1);
}
/* ------------------------------------------------------------------ */
/* JNI: Start an iperf3 test with the given arguments. */
/* Signature: void runIperfLive(String[] args, IperfCallback cb) */
@@ -179,26 +245,47 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
iperf_defaults(test);
g_test = test;
/* Convert Java String[] -> char** argv */
int argc = 0;
char **argv = java_string_array_to_argv(env, arguments, &argc);
/*
* Convert Java String[] -> char** argv, prepending "iperf3" as argv[0].
* iperf_parse_arguments() expects argv[0] to be the program name;
* passing args directly from Dart means argv[0] would be "-c",
* which iperf3 misinterprets as the program name, not the client flag.
*/
int dart_argc = (*env)->GetArrayLength(env, arguments);
int total_argc = dart_argc + 1; /* +1 for "iperf3" */
char **argv = malloc((total_argc + 1) * sizeof(char *));
if (!argv) {
const char *err = "Failed to parse arguments";
signal_error((struct iperf_run_state *) 0, err);
const char *err = "Failed to allocate argv";
LOGE("%s", err);
iperf_free_test(test);
g_test = NULL;
return;
}
argv[0] = strdup("iperf3");
for (int i = 0; i < dart_argc; i++) {
jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, arguments, i);
const char *cstr = (*env)->GetStringUTFChars(env, jstr, NULL);
if (cstr) {
argv[i + 1] = strdup(cstr);
(*env)->ReleaseStringUTFChars(env, jstr, cstr);
} else {
argv[i + 1] = strdup("");
}
(*env)->DeleteLocalRef(env, jstr);
}
argv[total_argc] = NULL; /* NULL-terminated for iperf */
/* Set a reasonable connect timeout (30s) to avoid hanging on DNS resolution.
* Default iperf3 connect timeout is 120s which is far too long. */
test->settings->connect_timeout = 30000;
/* Parse arguments */
if (iperf_parse_arguments(test, argc, argv) < 0) {
if (iperf_parse_arguments(test, total_argc, argv) < 0) {
char errbuf[256];
snprintf(errbuf, sizeof(errbuf), "Argument parse error: %s",
iperf_strerror(i_errno));
free_argv(argv, argc);
signal_error((struct iperf_run_state *) 0, errbuf);
snprintf(errbuf, sizeof(errbuf),
"Argument parse error: %s", iperf_strerror(i_errno));
LOGE("Parse error: %s", errbuf);
free_argv(argv, total_argc);
iperf_free_test(test);
g_test = NULL;
return;
@@ -207,7 +294,7 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
/* Create a pipe to capture iperf's stdout/stderr */
int pipefd[2];
if (pipe(pipefd) != 0) {
free_argv(argv, argc);
free_argv(argv, total_argc);
const char *err = "Failed to create output pipe";
LOGE("pipe() failed: %s", strerror(errno));
@@ -222,7 +309,7 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
struct iperf_run_state *state =
calloc(1, sizeof(struct iperf_run_state));
if (!state) {
free_argv(argv, argc);
free_argv(argv, total_argc);
close(pipefd[0]);
close(pipefd[1]);
iperf_free_test(test);
@@ -253,60 +340,79 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
test->outfile = state->outfile;
setvbuf(test->outfile, NULL, _IOLBF, 0); /* Line-buffered */
/* Notify Java that the test is starting */
signal_output(state, "[iPerf JNI] Starting iPerf3 client...\n");
/* Set temp directory for stream buffers (mkstemp in iperf_new_stream).
* On Android, /data/local/tmp is NOT writable from the app sandbox —
* use the app's cache dir instead. iperf_set_test_template() handles
* the strdup internally. */
iperf_set_test_template(test, g_tmp_template);
/* Spawn reader thread to forward iperf output to Java */
if (pthread_create(&state->reader_thread, NULL,
reader_thread_func, state) != 0) {
free_argv(argv, argc);
close(pipefd[0]);
close(pipefd[1]);
const char *err = "Failed to start output reader thread";
LOGE("%s", err);
signal_error(state, err);
iperf_free_test(test);
g_test = NULL;
free(state);
return;
}
/*
* Set up signal handling BEFORE calling iperf_run_client().
* iperf3's run() function uses setjmp/longjmp for signal handling.
* - setjmp returns 0 on first call (normal path)
* - setjmp returns non-zero when called via longjmp (signal path)
*
* Both paths share the same `state`, `argv`, `argc` variables,
* so the stack frame is preserved across the longjmp.
*/
if (setjmp(g_sigend_jmp_buf) == 0) {
/* Normal path — run the test */
signal(SIGPIPE, SIG_IGN); /* Ignore SIGPIPE for socket errors */
iperf_catch_sigend(sigend_handler);
/* Run the test */
g_stop = 0;
int ret = iperf_run_client(test);
/* Notify Java that the test is starting */
signal_output(state, "[iPerf JNI] Starting iPerf3 client...\n");
/* Wait for reader thread to finish */
pthread_join(state->reader_thread, NULL);
/* Spawn reader thread to forward iperf output to Java */
if (pthread_create(&state->reader_thread, NULL,
reader_thread_func, state) != 0) {
free_argv(argv, total_argc);
close(pipefd[0]);
close(pipefd[1]);
const char *err = "Failed to start output reader thread";
LOGE("%s", err);
signal_error(state, err);
iperf_free_test(test);
g_test = NULL;
free(state);
return;
}
/* Flush and close output */
fflush(state->outfile);
fclose(state->outfile);
/* Run the test */
g_stop = 0;
int ret = iperf_run_client(test);
LOGI("iperf_run_client returned: %d i_errno=%d strerror=%s ctrl_sck=%d", ret, i_errno, iperf_strerror(i_errno), test->ctrl_sck);
/* Clean up iperf */
iperf_free_test(test);
g_test = NULL;
/* Free argv */
free_argv(argv, argc);
/* Signal result to Java */
if (g_stop) {
signal_output(state, "[iPerf JNI] Test was stopped by user.\n");
} else if (ret < 0) {
char errbuf[256];
snprintf(errbuf, sizeof(errbuf), "[iPerf JNI] Test failed: %s\n",
iperf_strerror(i_errno));
signal_error(state, errbuf);
/*
* We get here after iperf_run_client returns normally.
* If a signal arrives during iperf_run_client(), the
* sigend_handler fires longjmp() and we never reach here.
*/
iperf_cleanup_and_signal(env, state, argv, total_argc, ret, 0);
} else {
signal_output(state, "[iPerf JNI] Test completed successfully.\n");
/*
* We got here via longjmp() from sigend_handler.
* Clean up resources allocated before the setjmp.
* NOTE: state, argv, argc are still valid on the stack.
*/
LOGI("Test interrupted by signal %d", g_signed_sig);
/* Stop flag for cleanup */
g_stop = 1;
/* Close the output file (the pipe write end) */
if (state->outfile) {
fclose(state->outfile);
}
/* Free argv (argv is still valid across longjmp since it's on stack) */
free_argv(argv, total_argc);
/* Run the cleanup with stopped=true.
* Note: argv is NULL, argc=0 so free_argv is a no-op.
* The state struct will be freed inside this function. */
iperf_cleanup_and_signal(env, state, NULL, 0, 0, 1);
}
/* Signal completion to Java */
signal_complete(state);
/* Free global ref and state struct */
(*env)->DeleteGlobalRef(env, state->callback_ref);
free(state);
}
/* ------------------------------------------------------------------ */
@@ -349,3 +455,20 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_isTestRunning(JNIEnv *env,
jobject thiz) {
return (g_test != NULL && !g_test->done);
}
/* ------------------------------------------------------------------ */
/* JNI: Set the temp directory for stream buffers. */
/* Signature: void setTempDir(String tmpDir) */
/* ------------------------------------------------------------------ */
JNIEXPORT void JNICALL
Java_com_novoyuuparosk_iperf3gui_IperfRunner_setTempDir(JNIEnv *env,
jobject thiz,
jstring tmpDir) {
if (!tmpDir) return;
const char *cstr = (*env)->GetStringUTFChars(env, tmpDir, NULL);
if (cstr) {
snprintf(g_tmp_template, sizeof(g_tmp_template), "%s/iperf3.XXXXXX", cstr);
(*env)->ReleaseStringUTFChars(env, tmpDir, cstr);
LOGI("Temp dir set to: %s", g_tmp_template);
}
}
@@ -1,70 +1,84 @@
package com.novoyuuparosk.iperf3gui
import android.os.Handler
import android.os.Looper
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
/**
* Kotlin binding layer for native iperf3 via JNI.
*
* This class loads the native library (libiperf3jni.so) and exposes
* iperf3 functionality to Flutter via MethodChannel callbacks.
*
* 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.
* the IperfCallback interface, with all callbacks posted to the main thread.
*/
object IperfRunner : FlutterPlugin {
private var channel: MethodChannel? = null
private var appContext: android.content.Context? = null
private val mainHandler = Handler(Looper.getMainLooper())
init {
// Load native library at class initialization
System.loadLibrary("iperf3jni")
try {
android.util.Log.i("IperfRunner", "Loading native library...")
System.loadLibrary("iperf3jni")
android.util.Log.i("IperfRunner", "Native library loaded successfully")
} catch (e: UnsatisfiedLinkError) {
android.util.Log.e("IperfRunner", "Failed to load native library: ${e.message}")
e.printStackTrace()
}
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
appContext = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "com.novoyuuparosk.iperf3gui/iperf")
channel?.setMethodCallHandler { call, result ->
when (call.method) {
"runIperf" -> {
val args = call.argument<List<String>>("args")?.toTypedArray()
if (args != null) {
runIperfLive(args, object : IperfCallback {
override fun onOutput(line: String) {
// 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))
}
override fun onError(error: String) {
},
onError = { error ->
channel?.invokeMethod("onError", mapOf("error" to error))
}
override fun onComplete() {
},
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" -> {
forceStopIperfTest(object : IperfCallback {
override fun onOutput(line: String) {
val safeCallback = MainThreadCallback(
onOutput = { line ->
channel?.invokeMethod("onOutput", mapOf("output" to line))
}
override fun onError(error: String) {
},
onError = { error ->
channel?.invokeMethod("onError", mapOf("error" to error))
}
override fun onComplete() {
},
onComplete = {
channel?.invokeMethod("onComplete", emptyMap<String, Any>())
}
})
)
forceStopIperfTest(safeCallback)
result.success(null)
}
"isTestRunning" -> {
result.success(isTestRunning())
}
else -> {
result.notImplemented()
}
else -> result.notImplemented()
}
}
}
@@ -77,37 +91,39 @@ object IperfRunner : FlutterPlugin {
// Native method bindings
// ─────────────────────────────────────────────────────────────────
/**
* Start an iperf3 test with the given arguments.
* This calls the native C function via JNI.
*/
external fun runIperfLive(arguments: Array<String>, callback: IperfCallback)
/**
* Gracefully stop a running iperf3 test.
*/
external fun forceStopIperfTest(callback: IperfCallback)
/**
* Check if an iperf3 test is currently running.
* @return true if a test is in progress, false otherwise
*/
external fun setTempDir(tmpDir: String)
external fun isTestRunning(): Boolean
/**
* Callback interface for iperf3 test output.
*
* Used by the native layer to stream test results back to Flutter
* in real-time.
* Used by the native layer to stream results back to Flutter in real-time.
*/
interface IperfCallback {
/** Called with each line of iperf3 output (stdout/stderr) */
fun onOutput(line: String)
/** Called when an error occurs */
fun onError(error: String)
/** Called when the test completes (success or failure) */
fun onComplete()
}
}
/**
* 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.
*/
private class MainThreadCallback(
private val onOutput: (String) -> Unit,
private val onError: (String) -> Unit,
private val onComplete: () -> Unit
) : IperfCallback {
override fun onOutput(line: String) {
mainHandler.post { onOutput(line) }
}
override fun onError(error: String) {
mainHandler.post { onError(error) }
}
override fun onComplete() {
mainHandler.post { onComplete() }
}
}
}